mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-24 19:07:20 +03:00
Compare commits
249 Commits
docker_env
...
v21.08.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -37,10 +37,8 @@ MAIL_FROM=bookstack@example.com
|
|||||||
# SMTP mail options
|
# SMTP mail options
|
||||||
# These settings can be checked using the "Send a Test Email"
|
# These settings can be checked using the "Send a Test Email"
|
||||||
# feature found in the "Settings > Maintenance" area of the system.
|
# feature found in the "Settings > Maintenance" area of the system.
|
||||||
# For more detailed documentation on mail options, refer to:
|
|
||||||
# https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration
|
|
||||||
MAIL_HOST=localhost
|
MAIL_HOST=localhost
|
||||||
MAIL_PORT=587
|
MAIL_PORT=1025
|
||||||
MAIL_USERNAME=null
|
MAIL_USERNAME=null
|
||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_ENCRYPTION=null
|
MAIL_ENCRYPTION=null
|
||||||
|
|||||||
@@ -3,10 +3,6 @@
|
|||||||
# Each option is shown with it's default value.
|
# Each option is shown with it's default value.
|
||||||
# Do not copy this whole file to use as your '.env' file.
|
# Do not copy this whole file to use as your '.env' file.
|
||||||
|
|
||||||
# The details here only serve as a quick reference.
|
|
||||||
# Please refer to the BookStack documentation for full details:
|
|
||||||
# https://www.bookstackapp.com/docs/
|
|
||||||
|
|
||||||
# Application environment
|
# Application environment
|
||||||
# Can be 'production', 'development', 'testing' or 'demo'
|
# Can be 'production', 'development', 'testing' or 'demo'
|
||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
@@ -36,21 +32,17 @@ APP_LANG=en
|
|||||||
# APP_LANG will be used if such a header is not provided.
|
# APP_LANG will be used if such a header is not provided.
|
||||||
APP_AUTO_LANG_PUBLIC=true
|
APP_AUTO_LANG_PUBLIC=true
|
||||||
|
|
||||||
# Application timezones
|
# Application timezone
|
||||||
# The first option is used to determine what timezone is used for date storage.
|
# Used where dates are displayed such as on exported content.
|
||||||
# Leaving that as "UTC" is advised.
|
|
||||||
# The second option is used to set the timezone which will be used for date
|
|
||||||
# formatting and display. This defaults to the "APP_TIMEZONE" value.
|
|
||||||
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
|
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
|
||||||
APP_TIMEZONE=UTC
|
APP_TIMEZONE=UTC
|
||||||
APP_DISPLAY_TIMEZONE=UTC
|
|
||||||
|
|
||||||
# Application theme
|
# Application theme
|
||||||
# Used to specific a themes/<APP_THEME> folder where BookStack UI
|
# Used to specific a themes/<APP_THEME> folder where BookStack UI
|
||||||
# overrides can be made. Defaults to disabled.
|
# overrides can be made. Defaults to disabled.
|
||||||
APP_THEME=false
|
APP_THEME=false
|
||||||
|
|
||||||
# Trusted proxies
|
# Trusted Proxies
|
||||||
# Used to indicate trust of systems that proxy to the application so
|
# 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
|
# certain header values (Such as "X-Forwarded-For") can be used from the
|
||||||
# incoming proxy request to provide origin detail.
|
# incoming proxy request to provide origin detail.
|
||||||
@@ -60,34 +52,26 @@ APP_PROXIES=null
|
|||||||
|
|
||||||
# Database details
|
# Database details
|
||||||
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
|
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
|
||||||
# An ipv6 address can be used via the square bracket format ([::1]).
|
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_DATABASE=database_database
|
DB_DATABASE=database_database
|
||||||
DB_USERNAME=database_username
|
DB_USERNAME=database_username
|
||||||
DB_PASSWORD=database_user_password
|
DB_PASSWORD=database_user_password
|
||||||
|
|
||||||
# MySQL specific connection options
|
# Mail system to use
|
||||||
# Path to Certificate Authority (CA) certificate file for your MySQL instance.
|
# Can be 'smtp' or 'sendmail'
|
||||||
# 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 configuration
|
|
||||||
# Refer to https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration
|
|
||||||
MAIL_DRIVER=smtp
|
MAIL_DRIVER=smtp
|
||||||
MAIL_FROM=bookstack@example.com
|
|
||||||
|
# Mail sending options
|
||||||
|
MAIL_FROM=mail@bookstackapp.com
|
||||||
MAIL_FROM_NAME=BookStack
|
MAIL_FROM_NAME=BookStack
|
||||||
|
|
||||||
|
# SMTP mail options
|
||||||
MAIL_HOST=localhost
|
MAIL_HOST=localhost
|
||||||
MAIL_PORT=587
|
MAIL_PORT=1025
|
||||||
MAIL_USERNAME=null
|
MAIL_USERNAME=null
|
||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_ENCRYPTION=null
|
MAIL_ENCRYPTION=null
|
||||||
MAIL_VERIFY_SSL=true
|
|
||||||
|
|
||||||
MAIL_SENDMAIL_COMMAND="/usr/sbin/sendmail -bs"
|
|
||||||
|
|
||||||
# Cache & Session driver to use
|
# Cache & Session driver to use
|
||||||
# Can be 'file', 'database', 'memcached' or 'redis'
|
# Can be 'file', 'database', 'memcached' or 'redis'
|
||||||
@@ -116,7 +100,8 @@ MEMCACHED_SERVERS=127.0.0.1:11211:100
|
|||||||
REDIS_SERVERS=127.0.0.1:6379:0
|
REDIS_SERVERS=127.0.0.1:6379:0
|
||||||
|
|
||||||
# Queue driver to use
|
# Queue driver to use
|
||||||
# Can be 'sync', 'database' or 'redis'
|
# Queue not really currently used but may be configurable in the future.
|
||||||
|
# Would advise not to change this for now.
|
||||||
QUEUE_CONNECTION=sync
|
QUEUE_CONNECTION=sync
|
||||||
|
|
||||||
# Storage system to use
|
# Storage system to use
|
||||||
@@ -149,13 +134,9 @@ STORAGE_S3_ENDPOINT=https://my-custom-s3-compatible.service.com:8001
|
|||||||
STORAGE_URL=false
|
STORAGE_URL=false
|
||||||
|
|
||||||
# Authentication method to use
|
# Authentication method to use
|
||||||
# Can be 'standard', 'ldap', 'saml2' or 'oidc'
|
# Can be 'standard', 'ldap' or 'saml2'
|
||||||
AUTH_METHOD=standard
|
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
|
# Social authentication configuration
|
||||||
# All disabled by default.
|
# All disabled by default.
|
||||||
# Refer to https://www.bookstackapp.com/docs/admin/third-party-auth/
|
# Refer to https://www.bookstackapp.com/docs/admin/third-party-auth/
|
||||||
@@ -220,11 +201,10 @@ LDAP_SERVER=false
|
|||||||
LDAP_BASE_DN=false
|
LDAP_BASE_DN=false
|
||||||
LDAP_DN=false
|
LDAP_DN=false
|
||||||
LDAP_PASS=false
|
LDAP_PASS=false
|
||||||
LDAP_USER_FILTER="(&(uid={user}))"
|
LDAP_USER_FILTER=false
|
||||||
LDAP_VERSION=false
|
LDAP_VERSION=false
|
||||||
LDAP_START_TLS=false
|
LDAP_START_TLS=false
|
||||||
LDAP_TLS_INSECURE=false
|
LDAP_TLS_INSECURE=false
|
||||||
LDAP_TLS_CA_CERT=false
|
|
||||||
LDAP_ID_ATTRIBUTE=uid
|
LDAP_ID_ATTRIBUTE=uid
|
||||||
LDAP_EMAIL_ATTRIBUTE=mail
|
LDAP_EMAIL_ATTRIBUTE=mail
|
||||||
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
|
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
|
||||||
@@ -237,7 +217,6 @@ LDAP_DUMP_USER_DETAILS=false
|
|||||||
LDAP_USER_TO_GROUPS=false
|
LDAP_USER_TO_GROUPS=false
|
||||||
LDAP_GROUP_ATTRIBUTE="memberOf"
|
LDAP_GROUP_ATTRIBUTE="memberOf"
|
||||||
LDAP_REMOVE_FROM_GROUPS=false
|
LDAP_REMOVE_FROM_GROUPS=false
|
||||||
LDAP_DUMP_USER_GROUPS=false
|
|
||||||
|
|
||||||
# SAML authentication configuration
|
# SAML authentication configuration
|
||||||
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
|
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
|
||||||
@@ -253,8 +232,6 @@ SAML2_ONELOGIN_OVERRIDES=null
|
|||||||
SAML2_DUMP_USER_DETAILS=false
|
SAML2_DUMP_USER_DETAILS=false
|
||||||
SAML2_AUTOLOAD_METADATA=false
|
SAML2_AUTOLOAD_METADATA=false
|
||||||
SAML2_IDP_AUTHNCONTEXT=true
|
SAML2_IDP_AUTHNCONTEXT=true
|
||||||
SAML2_SP_x509=null
|
|
||||||
SAML2_SP_x509_KEY=null
|
|
||||||
|
|
||||||
# SAML group sync configuration
|
# SAML group sync configuration
|
||||||
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
|
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
|
||||||
@@ -262,26 +239,6 @@ SAML2_USER_TO_GROUPS=false
|
|||||||
SAML2_GROUP_ATTRIBUTE=group
|
SAML2_GROUP_ATTRIBUTE=group
|
||||||
SAML2_REMOVE_FROM_GROUPS=false
|
SAML2_REMOVE_FROM_GROUPS=false
|
||||||
|
|
||||||
# OpenID Connect authentication configuration
|
|
||||||
# Refer to https://www.bookstackapp.com/docs/admin/oidc-auth/
|
|
||||||
OIDC_NAME=SSO
|
|
||||||
OIDC_DISPLAY_NAME_CLAIMS=name
|
|
||||||
OIDC_CLIENT_ID=null
|
|
||||||
OIDC_CLIENT_SECRET=null
|
|
||||||
OIDC_ISSUER=null
|
|
||||||
OIDC_ISSUER_DISCOVER=false
|
|
||||||
OIDC_PUBLIC_KEY=null
|
|
||||||
OIDC_AUTH_ENDPOINT=null
|
|
||||||
OIDC_TOKEN_ENDPOINT=null
|
|
||||||
OIDC_USERINFO_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
|
|
||||||
OIDC_EXTERNAL_ID_CLAIM=sub
|
|
||||||
OIDC_END_SESSION_ENDPOINT=false
|
|
||||||
|
|
||||||
# Disable default third-party services such as Gravatar and Draw.IO
|
# Disable default third-party services such as Gravatar and Draw.IO
|
||||||
# Service-specific options will override this option
|
# Service-specific options will override this option
|
||||||
DISABLE_EXTERNAL_SERVICES=false
|
DISABLE_EXTERNAL_SERVICES=false
|
||||||
@@ -295,7 +252,7 @@ AVATAR_URL=
|
|||||||
# Enable diagrams.net integration
|
# Enable diagrams.net integration
|
||||||
# Can simply be true/false to enable/disable the 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.
|
# 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
|
DRAWIO=true
|
||||||
|
|
||||||
# Default item listing view
|
# Default item listing view
|
||||||
@@ -312,7 +269,7 @@ APP_DEFAULT_DARK_MODE=false
|
|||||||
# Page revision limit
|
# Page revision limit
|
||||||
# Number of page revisions to keep in the system before deleting old revisions.
|
# Number of page revisions to keep in the system before deleting old revisions.
|
||||||
# If set to 'false' a limit will not be enforced.
|
# If set to 'false' a limit will not be enforced.
|
||||||
REVISION_LIMIT=100
|
REVISION_LIMIT=50
|
||||||
|
|
||||||
# Recycle Bin Lifetime
|
# Recycle Bin Lifetime
|
||||||
# The number of days that content will remain in the recycle bin before
|
# The number of days that content will remain in the recycle bin before
|
||||||
@@ -322,35 +279,6 @@ REVISION_LIMIT=100
|
|||||||
# Set to -1 for unlimited recycle bin lifetime.
|
# Set to -1 for unlimited recycle bin lifetime.
|
||||||
RECYCLE_BIN_LIFETIME=30
|
RECYCLE_BIN_LIFETIME=30
|
||||||
|
|
||||||
# File Upload Limit
|
|
||||||
# Maximum file size, in megabytes, that can be uploaded to the system.
|
|
||||||
FILE_UPLOAD_SIZE_LIMIT=50
|
|
||||||
|
|
||||||
# Export Page Size
|
|
||||||
# Primarily used to determine page size of PDF exports.
|
|
||||||
# Can be 'a4' or 'letter'.
|
|
||||||
EXPORT_PAGE_SIZE=a4
|
|
||||||
|
|
||||||
# Export PDF Command
|
|
||||||
# Set a command which can be used to convert a HTML file into a PDF file.
|
|
||||||
# When false this will not be used.
|
|
||||||
# String values represent the command to be called for conversion.
|
|
||||||
# Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
|
|
||||||
# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
|
|
||||||
EXPORT_PDF_COMMAND=false
|
|
||||||
|
|
||||||
# Export PDF Command Timeout
|
|
||||||
# The number of seconds that the export PDF command will run before a timeout occurs.
|
|
||||||
# Only applies for the EXPORT_PDF_COMMAND option, not for DomPDF or wkhtmltopdf.
|
|
||||||
EXPORT_PDF_COMMAND_TIMEOUT=15
|
|
||||||
|
|
||||||
# Set path to wkhtmltopdf binary for PDF generation.
|
|
||||||
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
|
|
||||||
# When false, BookStack will attempt to find a wkhtmltopdf in the application
|
|
||||||
# root folder then fall back to the default dompdf renderer if no binary exists.
|
|
||||||
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
|
||||||
WKHTMLTOPDF=false
|
|
||||||
|
|
||||||
# Allow <script> tags in page content
|
# Allow <script> tags in page content
|
||||||
# Note, if set to 'true' the page editor may still escape scripts.
|
# Note, if set to 'true' the page editor may still escape scripts.
|
||||||
ALLOW_CONTENT_SCRIPTS=false
|
ALLOW_CONTENT_SCRIPTS=false
|
||||||
@@ -373,22 +301,6 @@ ALLOW_UNTRUSTED_SERVER_FETCHING=false
|
|||||||
# Setting this option will also auto-adjust cookies to be SameSite=None.
|
# Setting this option will also auto-adjust cookies to be SameSite=None.
|
||||||
ALLOWED_IFRAME_HOSTS=null
|
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"
|
|
||||||
|
|
||||||
# A list of the sources/hostnames that can be reached by application SSR calls.
|
|
||||||
# This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
|
|
||||||
# Host-specific functionality (usually controlled via other options) like auth
|
|
||||||
# or user avatars for example, won't use this list.
|
|
||||||
# Space seperated if multiple. Can use '*' as a wildcard.
|
|
||||||
# Values will be compared prefix-matched, case-insensitive, against called SSR urls.
|
|
||||||
# Defaults to allow all hosts.
|
|
||||||
ALLOWED_SSR_HOSTS="*"
|
|
||||||
|
|
||||||
# The default and maximum item-counts for listing API requests.
|
# The default and maximum item-counts for listing API requests.
|
||||||
API_DEFAULT_ITEM_COUNT=100
|
API_DEFAULT_ITEM_COUNT=100
|
||||||
API_MAX_ITEM_COUNT=500
|
API_MAX_ITEM_COUNT=500
|
||||||
@@ -403,11 +315,3 @@ API_REQUESTS_PER_MIN=180
|
|||||||
# user identifier (Username or email).
|
# user identifier (Username or email).
|
||||||
LOG_FAILED_LOGIN_MESSAGE=false
|
LOG_FAILED_LOGIN_MESSAGE=false
|
||||||
LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
|
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
|
# These are supported funding model platforms
|
||||||
|
|
||||||
github: [ssddanbrown]
|
github: [ssddanbrown]
|
||||||
ko_fi: ssddanbrown
|
|
||||||
17
.github/ISSUE_TEMPLATE/api_request.md
vendored
Normal file
17
.github/ISSUE_TEMPLATE/api_request.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
name: New API Endpoint or Feature
|
||||||
|
about: Request a new endpoint or API feature be added
|
||||||
|
labels: ":nut_and_bolt: API Request"
|
||||||
|
---
|
||||||
|
|
||||||
|
#### API Endpoint or Feature
|
||||||
|
|
||||||
|
Clearly describe what you'd like to have added to the API.
|
||||||
|
|
||||||
|
#### Use-Case
|
||||||
|
|
||||||
|
Explain the use-case that you're working-on that requires the above request.
|
||||||
|
|
||||||
|
#### Additional Context
|
||||||
|
|
||||||
|
If required, add any other context about the feature request here.
|
||||||
25
.github/ISSUE_TEMPLATE/api_request.yml
vendored
25
.github/ISSUE_TEMPLATE/api_request.yml
vendored
@@ -1,25 +0,0 @@
|
|||||||
name: New API Endpoint or API Ability
|
|
||||||
description: Request a new endpoint or API feature be added
|
|
||||||
labels: [":nut_and_bolt: API Request"]
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: feature
|
|
||||||
attributes:
|
|
||||||
label: API Endpoint or Feature
|
|
||||||
description: Clearly describe what you'd like to have added to the API.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: usecase
|
|
||||||
attributes:
|
|
||||||
label: Use-Case
|
|
||||||
description: Explain the use-case that you're working-on that requires the above request.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: context
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Add any other context about the feature request here.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**Steps To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Your Configuration (please complete the following information):**
|
||||||
|
- Exact BookStack Version (Found in settings):
|
||||||
|
- PHP Version:
|
||||||
|
- Hosting Method (Nginx/Apache/Docker):
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
61
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
61
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,61 +0,0 @@
|
|||||||
name: Bug Report
|
|
||||||
description: Create a report to help us fix bugs & issues in existing supported functionality
|
|
||||||
labels: [":bug: Bug"]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for taking the time to fill out a bug report!
|
|
||||||
Please note that this form is for reporting bugs in existing supported functionality.
|
|
||||||
|
|
||||||
If you are reporting something that's not an issue in functionality we've previously supported and/or is simply something different to your expectations, then it may be more appropriate to raise via a feature or support request instead.
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: Describe the Bug
|
|
||||||
description: Provide a clear and concise description of what the bug is.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: reproduction
|
|
||||||
attributes:
|
|
||||||
label: Steps to Reproduce
|
|
||||||
description: Detail the steps that would replicate this issue.
|
|
||||||
placeholder: |
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: expected
|
|
||||||
attributes:
|
|
||||||
label: Expected Behaviour
|
|
||||||
description: Provide clear and concise description of what you expected to happen.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: context
|
|
||||||
attributes:
|
|
||||||
label: Screenshots or Additional Context
|
|
||||||
description: Provide any additional context and screenshots here to help us solve this issue.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: input
|
|
||||||
id: browserdetails
|
|
||||||
attributes:
|
|
||||||
label: Browser Details
|
|
||||||
description: |
|
|
||||||
If this is an issue that occurs when using the BookStack interface, please provide details of the browser used which presents the reported issue.
|
|
||||||
placeholder: (eg. Firefox 97 (64-bit) on Windows 11)
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: input
|
|
||||||
id: bsversion
|
|
||||||
attributes:
|
|
||||||
label: Exact BookStack Version
|
|
||||||
description: This can be found in the settings view of BookStack. Please provide an exact version(s) you've tested on.
|
|
||||||
placeholder: (eg. v23.06.7)
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
10
.github/ISSUE_TEMPLATE/config.yml
vendored
10
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,13 +1,9 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Discord Chat Support
|
- name: Discord chat support
|
||||||
url: https://discord.gg/ztkBqR2
|
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
|
- name: Debugging & Common Issues
|
||||||
url: https://www.bookstackapp.com/docs/admin/debugging/
|
url: https://www.bookstackapp.com/docs/admin/debugging/
|
||||||
about: Find details on how to debug issues and view common issues with their resolutions.
|
about: Find details on how to debug issues and view common issues with thier resolutions.
|
||||||
|
|
||||||
- name: Official Support Plans
|
|
||||||
url: https://www.bookstackapp.com/support/
|
|
||||||
about: View our official support plans that offer assured support for business.
|
|
||||||
|
|||||||
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the feature you'd like**
|
||||||
|
A clear description of the feature you'd like implemented in BookStack.
|
||||||
|
|
||||||
|
**Describe the benefits this feature would bring to BookStack users**
|
||||||
|
Explain the measurable benefits this feature would achieve.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
58
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
58
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,58 +0,0 @@
|
|||||||
name: Feature Request
|
|
||||||
description: Request a new feature or idea to be added to BookStack
|
|
||||||
labels: [":hammer: Feature Request"]
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: Describe the feature you'd like
|
|
||||||
description: Provide a clear description of the feature you'd like implemented in BookStack
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: benefits
|
|
||||||
attributes:
|
|
||||||
label: Describe the benefits this would bring to existing BookStack users
|
|
||||||
description: |
|
|
||||||
Explain the measurable benefits this feature would achieve for existing BookStack users.
|
|
||||||
These benefits should details outcomes in terms of what this request solves/achieves, and should not be specific to implementation.
|
|
||||||
This helps us understand the core desired goal so that a variety of potential implementations could be explored.
|
|
||||||
This field is important. Lack if input here may lead to early issue closure.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: already_achieved
|
|
||||||
attributes:
|
|
||||||
label: Can the goal of this request already be achieved via other means?
|
|
||||||
description: |
|
|
||||||
Yes/No. If yes, please describe how the requested approach fits in with the existing method.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: checkboxes
|
|
||||||
id: confirm-search
|
|
||||||
attributes:
|
|
||||||
label: Have you searched for an existing open/closed issue?
|
|
||||||
description: |
|
|
||||||
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundamental benefit/goal of your request.
|
|
||||||
options:
|
|
||||||
- label: I have searched for existing issues and none cover my fundamental request
|
|
||||||
required: true
|
|
||||||
- type: dropdown
|
|
||||||
id: existing_usage
|
|
||||||
attributes:
|
|
||||||
label: How long have you been using BookStack?
|
|
||||||
options:
|
|
||||||
- Not using yet, just scoping
|
|
||||||
- Under 3 months
|
|
||||||
- 3 months to 1 year
|
|
||||||
- 1 to 5 years
|
|
||||||
- Over 5 years
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: context
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Add any other context or screenshots about the feature request here.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
13
.github/ISSUE_TEMPLATE/language_request.md
vendored
Normal file
13
.github/ISSUE_TEMPLATE/language_request.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
name: Language Request
|
||||||
|
about: Request a new language to be added to Crowdin for you to translate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Language To Add
|
||||||
|
|
||||||
|
_Specify here the language you want to add._
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
_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._
|
||||||
31
.github/ISSUE_TEMPLATE/language_request.yml
vendored
31
.github/ISSUE_TEMPLATE/language_request.yml
vendored
@@ -1,31 +0,0 @@
|
|||||||
name: Language Request
|
|
||||||
description: Request a new language to be added to Crowdin for you to translate
|
|
||||||
labels: [":earth_africa: Translations"]
|
|
||||||
assignees:
|
|
||||||
- ssddanbrown
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for offering to help start a new translation for BookStack!
|
|
||||||
- type: input
|
|
||||||
id: language
|
|
||||||
attributes:
|
|
||||||
label: Language to Add
|
|
||||||
description: What language (and region if applicable) are you offering to help add to BookStack?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: checkboxes
|
|
||||||
id: confirm
|
|
||||||
attributes:
|
|
||||||
label: Confirmation of Intent
|
|
||||||
description: |
|
|
||||||
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.
|
|
||||||
required: true
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
*__Note: New languages are added at specific points of the development process so it may be a small while before the requested language is added for translation.__*
|
|
||||||
55
.github/ISSUE_TEMPLATE/support_request.yml
vendored
55
.github/ISSUE_TEMPLATE/support_request.yml
vendored
@@ -1,55 +0,0 @@
|
|||||||
name: Support Request
|
|
||||||
description: Request support for a specific problem you have not been able to solve yourself
|
|
||||||
labels: [":dog2: Support"]
|
|
||||||
body:
|
|
||||||
- type: checkboxes
|
|
||||||
id: useddocs
|
|
||||||
attributes:
|
|
||||||
label: Attempted Debugging
|
|
||||||
description: |
|
|
||||||
I have read the [BookStack debugging](https://www.bookstackapp.com/docs/admin/debugging/) page and seeked resolution or more
|
|
||||||
detail for the issue.
|
|
||||||
options:
|
|
||||||
- label: I have read the debugging page
|
|
||||||
required: true
|
|
||||||
- type: checkboxes
|
|
||||||
id: searchissue
|
|
||||||
attributes:
|
|
||||||
label: Searched GitHub Issues
|
|
||||||
description: |
|
|
||||||
I have searched for the issue and potential resolutions within the [project's GitHub issue list](https://github.com/BookStackApp/BookStack/issues)
|
|
||||||
options:
|
|
||||||
- label: I have searched GitHub for the issue.
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: scenario
|
|
||||||
attributes:
|
|
||||||
label: Describe the Scenario
|
|
||||||
description: Detail the problem that you're having or what you need support with.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
id: bsversion
|
|
||||||
attributes:
|
|
||||||
label: Exact BookStack Version
|
|
||||||
description: This can be found in the settings view of BookStack. Please provide an exact version.
|
|
||||||
placeholder: (eg. v23.06.7)
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: logs
|
|
||||||
attributes:
|
|
||||||
label: Log Content
|
|
||||||
description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below.
|
|
||||||
placeholder: Be sure to remove any confidential details in your logs
|
|
||||||
render: text
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: textarea
|
|
||||||
id: hosting
|
|
||||||
attributes:
|
|
||||||
label: Hosting Environment
|
|
||||||
description: Describe your hosting environment as much as possible including any proxies used (If applicable).
|
|
||||||
placeholder: (eg. PHP8.1 on Ubuntu 22.04 VPS, installed using official installation script)
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
9
.github/ISSUE_TEMPLATE/z_blank_request.yml
vendored
9
.github/ISSUE_TEMPLATE/z_blank_request.yml
vendored
@@ -1,9 +0,0 @@
|
|||||||
name: Blank Request (Maintainers Only)
|
|
||||||
description: For maintainers only - Start a blank request
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: "**This blank request option is only for existing official maintainers of the project!** Please instead use a different request option. If you use this your issue will be closed off."
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Description
|
|
||||||
27
.github/SECURITY.md
vendored
27
.github/SECURITY.md
vendored
@@ -1,27 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
## Supported Versions
|
|
||||||
|
|
||||||
Only the [latest version](https://github.com/BookStackApp/BookStack/releases) of BookStack is supported.
|
|
||||||
We generally don't support older versions of BookStack due to maintenance effort and
|
|
||||||
since we aim to provide a fairly stable upgrade path for new versions.
|
|
||||||
|
|
||||||
## Security Notifications
|
|
||||||
|
|
||||||
If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates).
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
|
||||||
|
|
||||||
If you've found an issue that likely has no impact to existing users (For example, in a development-only branch)
|
|
||||||
feel free to raise it via a standard GitHub bug report issue.
|
|
||||||
|
|
||||||
If the issue could have a security impact to BookStack instances,
|
|
||||||
please directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown).
|
|
||||||
You will need to log in to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
|
|
||||||
Alternatively you can send a DM via Mastodon to [@danb@fosstodon.org](https://fosstodon.org/@danb).
|
|
||||||
|
|
||||||
Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability
|
|
||||||
can often take a little time due to the amount of preparation required, to ensure the vulnerability has
|
|
||||||
been covered, and to create the content required to adequately notify the user-base.
|
|
||||||
|
|
||||||
Thank you for keeping BookStack instances safe!
|
|
||||||
331
.github/translators.txt
vendored
331
.github/translators.txt
vendored
@@ -55,9 +55,6 @@ Name :: Languages
|
|||||||
@Baptistou :: French
|
@Baptistou :: French
|
||||||
@arcoai :: Spanish
|
@arcoai :: Spanish
|
||||||
@Jokuna :: Korean
|
@Jokuna :: Korean
|
||||||
@smartshogu :: German; German Informal
|
|
||||||
@samadha56 :: Persian
|
|
||||||
@mrmuminov :: Uzbek
|
|
||||||
cipi1965 :: Italian
|
cipi1965 :: Italian
|
||||||
Mykola Ronik (Mantikor) :: Ukrainian
|
Mykola Ronik (Mantikor) :: Ukrainian
|
||||||
furkanoyk :: Turkish
|
furkanoyk :: Turkish
|
||||||
@@ -129,7 +126,7 @@ Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
|
|||||||
tatsuya.info :: Japanese
|
tatsuya.info :: Japanese
|
||||||
fadiapp :: Arabic
|
fadiapp :: Arabic
|
||||||
Jakub Bouček (jakubboucek) :: Czech
|
Jakub Bouček (jakubboucek) :: Czech
|
||||||
Marco (cdrfun) :: German; German Informal
|
Marco (cdrfun) :: German
|
||||||
10935336 :: Chinese Simplified
|
10935336 :: Chinese Simplified
|
||||||
孟繁阳 (FanyangMeng) :: Chinese Simplified
|
孟繁阳 (FanyangMeng) :: Chinese Simplified
|
||||||
Andrej Močan (andrejm) :: Slovenian
|
Andrej Močan (andrejm) :: Slovenian
|
||||||
@@ -139,9 +136,9 @@ Xiphoseer :: German
|
|||||||
MerlinSVK (merlinsvk) :: Slovak
|
MerlinSVK (merlinsvk) :: Slovak
|
||||||
Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
|
Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
|
||||||
MatthieuParis :: French
|
MatthieuParis :: French
|
||||||
Douradinho :: Portuguese, Brazilian; Portuguese
|
Douradinho :: Portuguese, Brazilian
|
||||||
Gaku Yaguchi (tama11) :: Japanese
|
Gaku Yaguchi (tama11) :: Japanese
|
||||||
Zero Huang (johnroyer) :: Chinese Traditional
|
johnroyer :: Chinese Traditional
|
||||||
jackaaa :: Chinese Traditional
|
jackaaa :: Chinese Traditional
|
||||||
Irfan Hukama Arsyad (IrfanArsyad) :: Indonesian
|
Irfan Hukama Arsyad (IrfanArsyad) :: Indonesian
|
||||||
Jeff Huang (s8321414) :: Chinese Traditional
|
Jeff Huang (s8321414) :: Chinese Traditional
|
||||||
@@ -161,14 +158,14 @@ HenrijsS :: Latvian
|
|||||||
Pascal R-B (pborgner) :: German
|
Pascal R-B (pborgner) :: German
|
||||||
Boris (Ginfred) :: Russian
|
Boris (Ginfred) :: Russian
|
||||||
Jonas Anker Rasmussen (jonasanker) :: Danish
|
Jonas Anker Rasmussen (jonasanker) :: Danish
|
||||||
Gerwin de Keijzer (gdekeijzer) :: Dutch; German Informal; German
|
Gerwin de Keijzer (gdekeijzer) :: Dutch; German; German Informal
|
||||||
kometchtech :: Japanese
|
kometchtech :: Japanese
|
||||||
Auri (Atalonica) :: Catalan
|
Auri (Atalonica) :: Catalan
|
||||||
Francesco Franchina (ffranchina) :: Italian
|
Francesco Franchina (ffranchina) :: Italian
|
||||||
Aimrane Kds (aimrane.kds) :: Arabic
|
Aimrane Kds (aimrane.kds) :: Arabic
|
||||||
whenwesober :: Indonesian
|
whenwesober :: Indonesian
|
||||||
Rem (remkovdhoef) :: Dutch
|
Rem (remkovdhoef) :: Dutch
|
||||||
syn7ax69 :: Bulgarian; Turkish; German
|
syn7ax69 :: Bulgarian; Turkish
|
||||||
Blaade :: French
|
Blaade :: French
|
||||||
Behzad HosseinPoor (behzad.hp) :: Persian
|
Behzad HosseinPoor (behzad.hp) :: Persian
|
||||||
Ole Aldric (Swoy) :: Norwegian Bokmal
|
Ole Aldric (Swoy) :: Norwegian Bokmal
|
||||||
@@ -177,7 +174,7 @@ Alexander Predl (Harveyhase68) :: German
|
|||||||
Rem (Rem9000) :: Dutch
|
Rem (Rem9000) :: Dutch
|
||||||
Michał Stelmach (stelmach-web) :: Polish
|
Michał Stelmach (stelmach-web) :: Polish
|
||||||
arniom :: French
|
arniom :: French
|
||||||
REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
|
REMOVED_USER :: Turkish
|
||||||
林祖年 (contagion) :: Chinese Traditional
|
林祖年 (contagion) :: Chinese Traditional
|
||||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||||
@@ -193,319 +190,3 @@ Hl2run :: Slovak
|
|||||||
Ngo Tri Hoai (trihoai) :: Vietnamese
|
Ngo Tri Hoai (trihoai) :: Vietnamese
|
||||||
Atalonica :: Catalan
|
Atalonica :: Catalan
|
||||||
慕容潭谈 (591442386) :: Chinese Simplified
|
慕容潭谈 (591442386) :: Chinese Simplified
|
||||||
Radim Pesek (ramess18) :: Czech
|
|
||||||
anastasiia.motylko :: Ukrainian
|
|
||||||
Indrek Haav (IndrekHaav) :: Estonian
|
|
||||||
na3shkw :: Japanese
|
|
||||||
Giancarlo Di Massa (digitall-it) :: Italian
|
|
||||||
M Nafis Al Mukhdi (mnafisalmukhdi1) :: Indonesian
|
|
||||||
sulfo :: Danish
|
|
||||||
Raukze :: German
|
|
||||||
zygimantus :: Lithuanian
|
|
||||||
marinkaberg :: Russian
|
|
||||||
Vitaliy (gviabcua) :: Ukrainian
|
|
||||||
mannycarreiro :: Portuguese
|
|
||||||
Thiago Rafael Pereira de Carvalho (thiago.rafael) :: Portuguese, Brazilian
|
|
||||||
Ken Roger Bolgnes (kenbo124) :: Norwegian Bokmal
|
|
||||||
Nguyen Hung Phuong (hnwolf) :: Vietnamese
|
|
||||||
Umut ERGENE (umutergene67) :: Turkish
|
|
||||||
Tomáš Batelka (Vofy) :: Czech
|
|
||||||
Mundo Racional (ismael.mesquita) :: Portuguese, Brazilian
|
|
||||||
Zarik (3apuk) :: Russian
|
|
||||||
Ali Shaatani (a.shaatani) :: Arabic
|
|
||||||
ChacMaster :: Portuguese, Brazilian
|
|
||||||
Saeed (saeed205) :: Persian
|
|
||||||
Julesdevops :: French
|
|
||||||
peter cerny (posli.to.semka) :: Slovak
|
|
||||||
Pavel Karlin (pavelkarlin) :: Russian
|
|
||||||
SmokingCrop :: Dutch
|
|
||||||
Maciej Lebiest (Szwendacz) :: Polish
|
|
||||||
DiscordDigital :: German; German Informal
|
|
||||||
Gábor Marton (dodver) :: Hungarian
|
|
||||||
Jasell :: Swedish
|
|
||||||
Ghost_chu (ghostchu) :: Chinese Simplified
|
|
||||||
Ravid Shachar (ravidshachar) :: Hebrew
|
|
||||||
Helga Guchshenskaya (guchshenskaya) :: Russian
|
|
||||||
daniel chou (chou0214) :: Chinese Traditional
|
|
||||||
Manolis PATRIARCHE (m.patriarche) :: French
|
|
||||||
Mohammed Haboubi (haboubi92) :: Arabic
|
|
||||||
roncallyt :: Portuguese, Brazilian
|
|
||||||
goegol :: Dutch
|
|
||||||
msevgen :: Turkish
|
|
||||||
Khroners :: French
|
|
||||||
MASOUD HOSSEINY (masoudme) :: Persian
|
|
||||||
Thomerson Roncally (roncallyt) :: Portuguese, Brazilian
|
|
||||||
metaarch :: Bulgarian
|
|
||||||
Xabi (xabikip) :: Basque
|
|
||||||
pedromcsousa :: Portuguese
|
|
||||||
Nir Louk (looknear) :: Hebrew
|
|
||||||
Alex (qianmengnet) :: Chinese Simplified
|
|
||||||
stothew :: German
|
|
||||||
sgenc :: Turkish
|
|
||||||
Shukrullo (vodiylik) :: Uzbek
|
|
||||||
William W. (Nevnt) :: Chinese Traditional
|
|
||||||
eamaro :: Portuguese
|
|
||||||
Ypsilon-dev :: Arabic
|
|
||||||
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
|
|
||||||
Jihyeon 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 Informal; German
|
|
||||||
Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
|
|
||||||
구인회 (laskdjlaskdj12) :: Korean
|
|
||||||
LiZerui (CNLiZerui) :: Chinese Traditional
|
|
||||||
Fabrice Boyer (FabriceBoyer) :: French
|
|
||||||
mikael (bitcanon) :: Swedish
|
|
||||||
Matthias Mai (schnapsidee) :: German Informal; 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
|
|
||||||
Angelos Chouvardas (achouvardas) :: Greek
|
|
||||||
rndrss :: Portuguese, Brazilian
|
|
||||||
rirac294 :: Russian
|
|
||||||
David Furman (thefourCraft) :: Hebrew
|
|
||||||
Pafzedog :: French
|
|
||||||
Yllelder :: Spanish
|
|
||||||
Adrian Ocneanu (aocneanu) :: Romanian
|
|
||||||
Eduardo Castanho (EduardoCastanho) :: Portuguese
|
|
||||||
VIET NAM VPS (vietnamvps) :: Vietnamese
|
|
||||||
m4tthi4s :: French
|
|
||||||
toras9000 :: Japanese
|
|
||||||
pathab :: German
|
|
||||||
MichelSchoon85 :: Dutch
|
|
||||||
Jøran Haugli (haugli92) :: Norwegian Bokmal
|
|
||||||
Vasileios Kouvelis (VasilisKouvelis) :: Greek
|
|
||||||
Dremski :: Bulgarian
|
|
||||||
Frédéric SENE (nothingfr) :: French
|
|
||||||
bendem :: French
|
|
||||||
kostasdizas :: Greek
|
|
||||||
Ricardo Schroeder (brownstone666) :: Portuguese, Brazilian
|
|
||||||
Eitan MG (EitanMG) :: Hebrew
|
|
||||||
Robin Flikkema (RobinFlikkema) :: Dutch
|
|
||||||
Michal Gurcik (mgurcik) :: Slovak
|
|
||||||
Pooyan Arab (pooyanarab) :: Persian
|
|
||||||
Ochi Darma Putra (troke12) :: Indonesian
|
|
||||||
Hsin-Hsiang Peng (Hsins) :: Chinese Traditional
|
|
||||||
Mosi Wang (mosiwang) :: Chinese Traditional
|
|
||||||
骆言 (LawssssCat) :: Chinese Simplified
|
|
||||||
Stickers Gaming Shøw (StickerSGSHOW) :: French
|
|
||||||
Le Van Chinh (Chino) (lvanchinh86) :: Vietnamese
|
|
||||||
Rubens nagios (rubenix) :: Catalan
|
|
||||||
Patrick Dantas (pa-tiq) :: Portuguese, Brazilian
|
|
||||||
Michal (michalgurcik) :: Slovak
|
|
||||||
Nepomacs :: German
|
|
||||||
Rubens (rubenix) :: Catalan
|
|
||||||
m4z :: German; German Informal
|
|
||||||
TheRazvy :: Romanian
|
|
||||||
Yossi Zilber (lortens) :: Hebrew; Uzbek
|
|
||||||
desdinova :: French
|
|
||||||
Ingus Rūķis (ingus.rukis) :: Latvian
|
|
||||||
Eugene Pershin (SilentEugene) :: Russian
|
|
||||||
周盛道 (zhoushengdao) :: Chinese Simplified
|
|
||||||
hamidreza amini (hamidrezaamini2022) :: Persian
|
|
||||||
Tomislav Kraljević (tomislav.kraljevic) :: Croatian
|
|
||||||
Taygun Yıldırım (yildirimtaygun) :: Turkish
|
|
||||||
robing29 :: German
|
|
||||||
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
|
|
||||||
Igor V Belousov (biv) :: Russian
|
|
||||||
David Bauer (davbauer) :: German; German Informal
|
|
||||||
Guttorm Hveem (guttormhveem) :: Norwegian Nynorsk; Norwegian Bokmal
|
|
||||||
Minh Giang Truong (minhgiang1204) :: Vietnamese
|
|
||||||
Ioannis Ioannides (i.ioannides) :: Greek
|
|
||||||
Vadim (vadrozh) :: Russian
|
|
||||||
Flip333 :: German Informal; German
|
|
||||||
Paulo Henrique (paulohsantos114) :: Portuguese, Brazilian
|
|
||||||
Dženan (Dzenan) :: Swedish
|
|
||||||
Péter Péli (peter.peli) :: Hungarian
|
|
||||||
TWME :: Chinese Traditional
|
|
||||||
Sascha (Man-in-Black) :: German; German Informal
|
|
||||||
Mohammadreza Madadi (madadi.efl) :: Persian
|
|
||||||
Konstantin (kkovacheli) :: Ukrainian; Russian
|
|
||||||
link1183 :: French
|
|
||||||
Renan (rfpe) :: Portuguese, Brazilian
|
|
||||||
Lowkey (bbsweb) :: Chinese Simplified
|
|
||||||
ZZnOB (zznobzz) :: Russian
|
|
||||||
rupus :: Swedish
|
|
||||||
developernecsys :: Norwegian Nynorsk
|
|
||||||
xuan LI (xuanli233) :: Chinese Simplified
|
|
||||||
LameeQS :: Latvian
|
|
||||||
Sorin T. (trimbitassorin) :: Romanian
|
|
||||||
poesty :: Chinese Simplified
|
|
||||||
balmag :: Hungarian
|
|
||||||
Antti-Jussi Nygård (ajnyga) :: Finnish
|
|
||||||
Eduard Ereza Martínez (Ereza) :: Catalan
|
|
||||||
Jabir Lang (amar.almrad) :: Arabic
|
|
||||||
Jaroslav Kobližek (foretix) :: Czech; French
|
|
||||||
Wiktor Adamczyk (adamczyk.wiktor) :: Polish
|
|
||||||
Abdulmajeed Alshuaibi (4Majeed) :: Arabic
|
|
||||||
NotSmartZakk :: Czech
|
|
||||||
HyoungMin Lee (ddokkaebi) :: Korean
|
|
||||||
Dasferco :: Chinese Simplified
|
|
||||||
Marcus Teräs (mteras) :: Finnish
|
|
||||||
Serkan Yardim (serkanzz) :: Turkish
|
|
||||||
Y (cnsr) :: Ukrainian
|
|
||||||
ZY ZV (vy0b0x) :: Chinese Simplified
|
|
||||||
diegobenitez :: Spanish
|
|
||||||
Marc Hagen (MarcHagen) :: Dutch
|
|
||||||
Kasper Alsøe (zeonos) :: Danish
|
|
||||||
sultani :: Persian
|
|
||||||
renge :: Korean
|
|
||||||
Tim (thegatesdev) :: Dutch; German Informal; French; Romanian; Catalan; Czech; Danish; German; Finnish; Hungarian; Italian; Japanese; Korean; Polish; Russian; Ukrainian; Chinese Simplified; Chinese Traditional; Portuguese, Brazilian; Persian; Spanish, Argentina; Croatian; Norwegian Nynorsk; Estonian; Uzbek; Norwegian Bokmal
|
|
||||||
Irdi (irdiOL) :: Albanian
|
|
||||||
KateBarber :: Welsh
|
|
||||||
Twister (theuncles75) :: Hebrew
|
|
||||||
algernon19 :: Hungarian
|
|
||||||
Ivan Krstic (ikrstic) :: Serbian (Cyrillic)
|
|
||||||
Show :: Russian
|
|
||||||
xBahamut :: Portuguese, Brazilian
|
|
||||||
Pavle Knežević (pavleknezzevic) :: Serbian (Cyrillic)
|
|
||||||
Vanja Cvelbar (b100w11) :: Slovenian
|
|
||||||
simonpct :: French
|
|
||||||
Honza Nagy (honza.nagy) :: Czech
|
|
||||||
asd20752 :: Norwegian Bokmal
|
|
||||||
Jan Picka (polipones) :: Czech
|
|
||||||
diogoalex991 :: Portuguese
|
|
||||||
Ehsan Sadeghi (ehsansadeghi) :: Persian
|
|
||||||
ka_picit :: Danish
|
|
||||||
cracrayol :: French
|
|
||||||
CapuaSC :: Dutch
|
|
||||||
Guardian75 :: German Informal
|
|
||||||
mr-kanister :: German
|
|
||||||
Michele Bastianelli (makoblaster) :: Italian
|
|
||||||
jespernissen :: Danish
|
|
||||||
Andrey (avmaksimov) :: Russian
|
|
||||||
Gonzalo Loyola (AlFcl) :: Spanish, Argentina; Spanish
|
|
||||||
grobert63 :: French
|
|
||||||
wusst. (Supporti) :: German
|
|
||||||
MaximMaximS :: Czech
|
|
||||||
damian-klima :: Slovak
|
|
||||||
crow_ :: Latvian
|
|
||||||
JocelynDelalande :: French
|
|
||||||
Jan (JW-CH) :: German Informal
|
|
||||||
Timo B (lommes) :: German Informal
|
|
||||||
Erik Lundstedt (Erik.Lundstedt) :: Swedish
|
|
||||||
yngams (younessmouhid) :: Arabic
|
|
||||||
Ohadp :: Hebrew
|
|
||||||
cbridi :: Portuguese, Brazilian
|
|
||||||
nanangsb :: Indonesian
|
|
||||||
Michal Melich (michalmelich) :: Czech
|
|
||||||
David (david-prv) :: German; German Informal
|
|
||||||
Larry (lahoje) :: Swedish
|
|
||||||
Marcia dos Santos (marciab80) :: Portuguese
|
|
||||||
Ricard López Torres (richilpez.torres) :: Catalan
|
|
||||||
sarahalves7 :: Portuguese, Brazilian
|
|
||||||
petr.husak :: Czech
|
|
||||||
javadataherian :: Persian
|
|
||||||
Ludo-code :: French
|
|
||||||
hollsten :: Swedish
|
|
||||||
Ngoc Lan Phung (lanpncz) :: Vietnamese
|
|
||||||
Worive :: Catalan; French
|
|
||||||
Илья Скаба (skabailya) :: Russian
|
|
||||||
Irjan Olsen (Irch) :: Norwegian Bokmal
|
|
||||||
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
|
|
||||||
Red (RedVortex) :: Hebrew
|
|
||||||
xgrug :: Chinese Simplified
|
|
||||||
HrCalmar :: Danish
|
|
||||||
Avishay Rapp (AvishayRapp) :: Hebrew
|
|
||||||
matthias4217 :: French
|
|
||||||
Berke BOYLU2 (berkeboylu2) :: Turkish
|
|
||||||
etwas7B :: German
|
|
||||||
Mohammed srhiri (m.sghiri20) :: Arabic
|
|
||||||
YongMin Kim (kym0118) :: Korean
|
|
||||||
Rivo Zängov (Eraser) :: Estonian
|
|
||||||
Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian
|
|
||||||
ИEØ_ΙΙØZ (NEO_IIOZ) :: Chinese Traditional
|
|
||||||
madnjpn (madnjpn.) :: Georgian
|
|
||||||
Ásgeir Shiny Ásgeirsson (AsgeirShiny) :: Icelandic
|
|
||||||
Mohammad Aftab Uddin (chirohorit) :: Bengali
|
|
||||||
Yannis Karlaftis (meliseus) :: Greek
|
|
||||||
felixxx :: German Informal
|
|
||||||
randi (randi65535) :: Korean
|
|
||||||
test65428 :: Greek
|
|
||||||
zeronell :: Chinese Simplified
|
|
||||||
julien Vinber (julienVinber) :: French
|
|
||||||
Hyunwoo Park (oksure) :: Korean
|
|
||||||
aram.rafeq.7 (aramrafeq2) :: Kurdish
|
|
||||||
Raphael Moreno (RaphaelMoreno) :: Portuguese, Brazilian
|
|
||||||
yn (user99) :: Arabic
|
|
||||||
Pavel Zlatarov (pzlatarov) :: Bulgarian
|
|
||||||
ingelres :: French
|
|
||||||
mabdullah :: Arabic
|
|
||||||
Skrabák Csaba (kekcsi) :: Hungarian
|
|
||||||
Evert Meulie (Evert) :: Norwegian Bokmal
|
|
||||||
Jasper Backer (jasperb) :: Dutch
|
|
||||||
Alexandar Cavdarovski (ace.200112) :: Swedish
|
|
||||||
구닥다리TV (yjj8353) :: Korean
|
|
||||||
Onur Oskay (o.oskay) :: Turkish
|
|
||||||
Sébastien Merveille (SebastienMerv) :: French
|
|
||||||
Maxim Kouznetsov (masya.work) :: Hebrew
|
|
||||||
neodvisnost :: Slovenian
|
|
||||||
Soubi Agatsuma (bisouya) :: Hebrew
|
|
||||||
Ilya Shaulov (ishaulov) :: Russian
|
|
||||||
Konstantin Bobkov (b.konstantv) :: Russian
|
|
||||||
Ruben Sutter (rubensutter) :: German
|
|
||||||
jellium :: French
|
|
||||||
Qxlkdr :: Swedish
|
|
||||||
Hari (muhhari) :: Indonesian
|
|
||||||
仙君御 (xjy) :: Chinese Simplified
|
|
||||||
TapioM :: Finnish
|
|
||||||
lingb58 :: Chinese Traditional
|
|
||||||
Angel Pandey (angel-pandey) :: Nepali
|
|
||||||
Supriya Shrestha (supriyashrestha) :: Nepali
|
|
||||||
gprabhat :: Nepali
|
|
||||||
CellCat :: Chinese Simplified
|
|
||||||
Al Desrahim (aldesrahim) :: Indonesian
|
|
||||||
ahmad abbaspour (deshneh.dar.diss) :: Persian
|
|
||||||
Erjon K. (ekr) :: Albanian
|
|
||||||
LiZerui (iamzrli) :: Chinese Traditional
|
|
||||||
Ticker (ticker.com) :: Hebrew
|
|
||||||
CrazyComputer :: Chinese Simplified
|
|
||||||
Firr (FirrV) :: Russian
|
|
||||||
João Faro (FaroJoaoFaro) :: Portuguese
|
|
||||||
Danilo dos Santos Barbosa (bozochegou) :: Portuguese, Brazilian
|
|
||||||
Chris (furesoft) :: German
|
|
||||||
Silvia Isern (eiendragon) :: Catalan
|
|
||||||
Dennis Kron Pedersen (ahjdp) :: Danish
|
|
||||||
iamwhoiamwhoami :: Swedish
|
|
||||||
Grogui :: French
|
|
||||||
MrCharlesIII :: Arabic
|
|
||||||
David Olsen (dawin) :: Danish
|
|
||||||
|
|||||||
40
.github/workflows/analyse-php.yml
vendored
40
.github/workflows/analyse-php.yml
vendored
@@ -1,40 +0,0 @@
|
|||||||
name: analyse-php
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- '**.php'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '**.php'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: 8.3
|
|
||||||
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
|
|
||||||
|
|
||||||
- name: Cache composer packages
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ${{ steps.composer-cache.outputs.dir }}
|
|
||||||
key: ${{ runner.os }}-composer-8.3
|
|
||||||
restore-keys: ${{ runner.os }}-composer-
|
|
||||||
|
|
||||||
- name: Install composer dependencies
|
|
||||||
run: composer install --prefer-dist --no-interaction --ansi
|
|
||||||
|
|
||||||
- name: Run static analysis check
|
|
||||||
run: composer check-static
|
|
||||||
24
.github/workflows/lint-js.yml
vendored
24
.github/workflows/lint-js.yml
vendored
@@ -1,24 +0,0 @@
|
|||||||
name: lint-js
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- '**.js'
|
|
||||||
- '**.json'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '**.js'
|
|
||||||
- '**.json'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install NPM deps
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run formatting check
|
|
||||||
run: npm run lint
|
|
||||||
25
.github/workflows/lint-php.yml
vendored
25
.github/workflows/lint-php.yml
vendored
@@ -1,25 +0,0 @@
|
|||||||
name: lint-php
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- '**.php'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '**.php'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: 8.3
|
|
||||||
tools: phpcs
|
|
||||||
|
|
||||||
- name: Run formatting check
|
|
||||||
run: composer lint
|
|
||||||
@@ -1,46 +1,42 @@
|
|||||||
name: test-php
|
name: phpunit
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
branches-ignore:
|
||||||
- '**.php'
|
- l10n_master
|
||||||
- 'composer.*'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
branches-ignore:
|
||||||
- '**.php'
|
- l10n_master
|
||||||
- 'composer.*'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
runs-on: ubuntu-20.04
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php: ['8.2', '8.3', '8.4']
|
php: ['7.3', '7.4', '8.0']
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v1
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@b7d1d9c9a92d8d8463ce36d7f60da34d461724f8
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php }}
|
php-version: ${{ matrix.php }}
|
||||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp
|
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||||
|
|
||||||
- name: Get Composer Cache Directory
|
- name: Get Composer Cache Directory
|
||||||
id: composer-cache
|
id: composer-cache
|
||||||
run: |
|
run: |
|
||||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||||
|
|
||||||
- name: Cache composer packages
|
- name: Cache composer packages
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v1
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.composer-cache.outputs.dir }}
|
path: ${{ steps.composer-cache.outputs.dir }}
|
||||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||||
restore-keys: ${{ runner.os }}-composer-
|
|
||||||
|
|
||||||
- name: Start Database
|
- name: Start Database
|
||||||
run: |
|
run: |
|
||||||
sudo systemctl start mysql
|
sudo /etc/init.d/mysql start
|
||||||
|
|
||||||
- name: Setup Database
|
- name: Setup Database
|
||||||
run: |
|
run: |
|
||||||
@@ -49,7 +45,7 @@ jobs:
|
|||||||
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
|
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
|
||||||
mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
|
mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
|
||||||
|
|
||||||
- name: Install composer dependencies
|
- name: Install composer dependencies & Test
|
||||||
run: composer install --prefer-dist --no-interaction --ansi
|
run: composer install --prefer-dist --no-interaction --ansi
|
||||||
|
|
||||||
- name: Migrate and seed the database
|
- name: Migrate and seed the database
|
||||||
@@ -57,5 +53,5 @@ jobs:
|
|||||||
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
||||||
php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --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
|
run: php${{ matrix.php }} ./vendor/bin/phpunit
|
||||||
29
.github/workflows/test-js.yml
vendored
29
.github/workflows/test-js.yml
vendored
@@ -1,29 +0,0 @@
|
|||||||
name: test-js
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- '**.js'
|
|
||||||
- '**.ts'
|
|
||||||
- '**.json'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '**.js'
|
|
||||||
- '**.ts'
|
|
||||||
- '**.json'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install NPM deps
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run TypeScript type checking
|
|
||||||
run: npm run ts:lint
|
|
||||||
|
|
||||||
- name: Run JavaScript tests
|
|
||||||
run: npm run test
|
|
||||||
26
.github/workflows/test-migrations.yml
vendored
26
.github/workflows/test-migrations.yml
vendored
@@ -2,26 +2,23 @@ name: test-migrations
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
branches-ignore:
|
||||||
- '**.php'
|
- l10n_master
|
||||||
- 'composer.*'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
branches-ignore:
|
||||||
- '**.php'
|
- l10n_master
|
||||||
- 'composer.*'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
runs-on: ubuntu-20.04
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php: ['8.2', '8.3', '8.4']
|
php: ['7.3', '7.4', '8.0']
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v1
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@b7d1d9c9a92d8d8463ce36d7f60da34d461724f8
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php }}
|
php-version: ${{ matrix.php }}
|
||||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||||
@@ -29,18 +26,17 @@ jobs:
|
|||||||
- name: Get Composer Cache Directory
|
- name: Get Composer Cache Directory
|
||||||
id: composer-cache
|
id: composer-cache
|
||||||
run: |
|
run: |
|
||||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||||
|
|
||||||
- name: Cache composer packages
|
- name: Cache composer packages
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v1
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.composer-cache.outputs.dir }}
|
path: ${{ steps.composer-cache.outputs.dir }}
|
||||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||||
restore-keys: ${{ runner.os }}-composer-
|
|
||||||
|
|
||||||
- name: Start MySQL
|
- name: Start MySQL
|
||||||
run: |
|
run: |
|
||||||
sudo systemctl start mysql
|
sudo /etc/init.d/mysql start
|
||||||
|
|
||||||
- name: Create database & user
|
- name: Create database & user
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -1,20 +1,16 @@
|
|||||||
/vendor
|
/vendor
|
||||||
/node_modules
|
/node_modules
|
||||||
/.vscode
|
|
||||||
/composer
|
|
||||||
/coverage
|
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
.env
|
.env
|
||||||
.idea
|
.idea
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
/public/dist
|
/public/dist/*.map
|
||||||
/public/plugins
|
/public/plugins
|
||||||
/public/css
|
/public/css/*.map
|
||||||
/public/js
|
/public/js/*.map
|
||||||
/public/bower
|
/public/bower
|
||||||
/public/build/
|
/public/build/
|
||||||
/public/favicon.ico
|
|
||||||
/storage/images
|
/storage/images
|
||||||
_ide_helper.php
|
_ide_helper.php
|
||||||
/storage/debugbar
|
/storage/debugbar
|
||||||
@@ -24,12 +20,7 @@ yarn.lock
|
|||||||
nbproject
|
nbproject
|
||||||
.buildpath
|
.buildpath
|
||||||
.project
|
.project
|
||||||
.nvmrc
|
|
||||||
.settings/
|
.settings/
|
||||||
webpack-stats.json
|
webpack-stats.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
.DS_Store
|
.DS_Store
|
||||||
phpstan.neon
|
|
||||||
esbuild-meta.json
|
|
||||||
.phpactor.json
|
|
||||||
/*.zip
|
|
||||||
3
LICENSE
3
LICENSE
@@ -1,6 +1,7 @@
|
|||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (c) 2015-2025, Dan Brown and the BookStack project contributors.
|
Copyright (c) 2020 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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Controllers;
|
|
||||||
|
|
||||||
use BookStack\Access\EmailConfirmationService;
|
|
||||||
use BookStack\Access\LoginService;
|
|
||||||
use BookStack\Exceptions\ConfirmationEmailException;
|
|
||||||
use BookStack\Exceptions\UserTokenExpiredException;
|
|
||||||
use BookStack\Exceptions\UserTokenNotFoundException;
|
|
||||||
use BookStack\Http\Controller;
|
|
||||||
use BookStack\Users\UserRepo;
|
|
||||||
use Exception;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class ConfirmEmailController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected EmailConfirmationService $emailConfirmationService,
|
|
||||||
protected LoginService $loginService,
|
|
||||||
protected UserRepo $userRepo
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the page to tell the user to check their email
|
|
||||||
* and confirm their address.
|
|
||||||
*/
|
|
||||||
public function show()
|
|
||||||
{
|
|
||||||
return view('auth.register-confirm');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows a notice that a user's email address has not been confirmed,
|
|
||||||
* along with the option to re-send the confirmation email.
|
|
||||||
*/
|
|
||||||
public function showAwaiting()
|
|
||||||
{
|
|
||||||
$user = $this->loginService->getLastLoginAttemptUser();
|
|
||||||
if ($user === null) {
|
|
||||||
$this->showErrorNotification(trans('errors.login_user_not_found'));
|
|
||||||
return redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('auth.register-confirm-awaiting');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the form for a user to provide their positive confirmation of their email.
|
|
||||||
*/
|
|
||||||
public function showAcceptForm(string $token)
|
|
||||||
{
|
|
||||||
return view('auth.register-confirm-accept', ['token' => $token]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirms an email via a token and logs the user into the system.
|
|
||||||
*
|
|
||||||
* @throws ConfirmationEmailException
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function confirm(Request $request)
|
|
||||||
{
|
|
||||||
$validated = $this->validate($request, [
|
|
||||||
'token' => ['required', 'string']
|
|
||||||
]);
|
|
||||||
|
|
||||||
$token = $validated['token'];
|
|
||||||
|
|
||||||
try {
|
|
||||||
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
|
|
||||||
} catch (UserTokenNotFoundException $exception) {
|
|
||||||
$this->showErrorNotification(trans('errors.email_confirmation_invalid'));
|
|
||||||
|
|
||||||
return redirect('/register');
|
|
||||||
} catch (UserTokenExpiredException $exception) {
|
|
||||||
$user = $this->userRepo->getById($exception->userId);
|
|
||||||
$this->emailConfirmationService->sendConfirmation($user);
|
|
||||||
$this->showErrorNotification(trans('errors.email_confirmation_expired'));
|
|
||||||
|
|
||||||
return redirect('/register/confirm');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->userRepo->getById($userId);
|
|
||||||
$user->email_confirmed = true;
|
|
||||||
$user->save();
|
|
||||||
|
|
||||||
$this->emailConfirmationService->deleteByUser($user);
|
|
||||||
$this->showSuccessNotification(trans('auth.email_confirm_success'));
|
|
||||||
|
|
||||||
return redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resend the confirmation email.
|
|
||||||
*/
|
|
||||||
public function resend()
|
|
||||||
{
|
|
||||||
$user = $this->loginService->getLastLoginAttemptUser();
|
|
||||||
if ($user === null) {
|
|
||||||
$this->showErrorNotification(trans('errors.login_user_not_found'));
|
|
||||||
return redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->emailConfirmationService->sendConfirmation($user);
|
|
||||||
} catch (ConfirmationEmailException $e) {
|
|
||||||
$this->showErrorNotification($e->getMessage());
|
|
||||||
|
|
||||||
return redirect('/login');
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$this->showErrorNotification(trans('auth.email_confirm_send_error'));
|
|
||||||
|
|
||||||
return redirect('/register/awaiting');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->showSuccessNotification(trans('auth.email_confirm_resent'));
|
|
||||||
|
|
||||||
return redirect('/register/confirm');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Controllers;
|
|
||||||
|
|
||||||
use BookStack\Access\Oidc\OidcException;
|
|
||||||
use BookStack\Access\Oidc\OidcService;
|
|
||||||
use BookStack\Http\Controller;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class OidcController extends Controller
|
|
||||||
{
|
|
||||||
protected OidcService $oidcService;
|
|
||||||
|
|
||||||
public function __construct(OidcService $oidcService)
|
|
||||||
{
|
|
||||||
$this->oidcService = $oidcService;
|
|
||||||
$this->middleware('guard:oidc');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the authorization login flow via OIDC.
|
|
||||||
*/
|
|
||||||
public function login()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$loginDetails = $this->oidcService->login();
|
|
||||||
} catch (OidcException $exception) {
|
|
||||||
$this->showErrorNotification($exception->getMessage());
|
|
||||||
|
|
||||||
return redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
session()->flash('oidc_state', $loginDetails['state']);
|
|
||||||
|
|
||||||
return redirect($loginDetails['url']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authorization flow redirect callback.
|
|
||||||
* Processes authorization response from the OIDC Authorization Server.
|
|
||||||
*/
|
|
||||||
public function callback(Request $request)
|
|
||||||
{
|
|
||||||
$storedState = session()->pull('oidc_state');
|
|
||||||
$responseState = $request->query('state');
|
|
||||||
|
|
||||||
if ($storedState !== $responseState) {
|
|
||||||
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
|
|
||||||
|
|
||||||
return redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->oidcService->processAuthorizeResponse($request->query('code'));
|
|
||||||
} catch (OidcException $oidcException) {
|
|
||||||
$this->showErrorNotification($oidcException->getMessage());
|
|
||||||
|
|
||||||
return redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->intended();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log the user out then start the OIDC RP-initiated logout process.
|
|
||||||
*/
|
|
||||||
public function logout()
|
|
||||||
{
|
|
||||||
return redirect($this->oidcService->logout());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Controllers;
|
|
||||||
|
|
||||||
use BookStack\Access\LoginService;
|
|
||||||
use BookStack\Access\RegistrationService;
|
|
||||||
use BookStack\Access\SocialDriverManager;
|
|
||||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
|
||||||
use BookStack\Exceptions\UserRegistrationException;
|
|
||||||
use BookStack\Http\Controller;
|
|
||||||
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Validation\Rules\Password;
|
|
||||||
|
|
||||||
class RegisterController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected SocialDriverManager $socialDriverManager,
|
|
||||||
protected RegistrationService $registrationService,
|
|
||||||
protected LoginService $loginService
|
|
||||||
) {
|
|
||||||
$this->middleware('guest');
|
|
||||||
$this->middleware('guard:standard');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the application registration form.
|
|
||||||
*
|
|
||||||
* @throws UserRegistrationException
|
|
||||||
*/
|
|
||||||
public function getRegister()
|
|
||||||
{
|
|
||||||
$this->registrationService->ensureRegistrationAllowed();
|
|
||||||
$socialDrivers = $this->socialDriverManager->getActive();
|
|
||||||
|
|
||||||
return view('auth.register', [
|
|
||||||
'socialDrivers' => $socialDrivers,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a registration request for the application.
|
|
||||||
*
|
|
||||||
* @throws UserRegistrationException
|
|
||||||
* @throws StoppedAuthenticationException
|
|
||||||
*/
|
|
||||||
public function postRegister(Request $request)
|
|
||||||
{
|
|
||||||
$this->registrationService->ensureRegistrationAllowed();
|
|
||||||
$this->validator($request->all())->validate();
|
|
||||||
$userData = $request->all();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$user = $this->registrationService->registerUser($userData);
|
|
||||||
$this->loginService->login($user, auth()->getDefaultDriver());
|
|
||||||
} catch (UserRegistrationException $exception) {
|
|
||||||
if ($exception->getMessage()) {
|
|
||||||
$this->showErrorNotification($exception->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect($exception->redirectLocation);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->showSuccessNotification(trans('auth.register_success'));
|
|
||||||
|
|
||||||
return redirect('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a validator for an incoming registration request.
|
|
||||||
*/
|
|
||||||
protected function validator(array $data): ValidatorContract
|
|
||||||
{
|
|
||||||
return Validator::make($data, [
|
|
||||||
'name' => ['required', 'min:2', 'max:100'],
|
|
||||||
'email' => ['required', 'email', 'max:255', 'unique:users'],
|
|
||||||
'password' => ['required', Password::default()],
|
|
||||||
// Basic honey for bots that must not be filled in
|
|
||||||
'username' => ['prohibited'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Controllers;
|
|
||||||
|
|
||||||
use BookStack\Access\LoginService;
|
|
||||||
use BookStack\Activity\ActivityType;
|
|
||||||
use BookStack\Http\Controller;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Facades\Password;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
|
||||||
|
|
||||||
class ResetPasswordController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected LoginService $loginService
|
|
||||||
) {
|
|
||||||
$this->middleware('guest');
|
|
||||||
$this->middleware('guard:standard');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display the password reset view for the given token.
|
|
||||||
* If no token is present, display the link request form.
|
|
||||||
*/
|
|
||||||
public function showResetForm(Request $request)
|
|
||||||
{
|
|
||||||
$token = $request->route()->parameter('token');
|
|
||||||
|
|
||||||
return view('auth.passwords.reset')->with(
|
|
||||||
['token' => $token, 'email' => $request->email]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the given user's password.
|
|
||||||
*/
|
|
||||||
public function reset(Request $request)
|
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'token' => 'required',
|
|
||||||
'email' => 'required|email',
|
|
||||||
'password' => ['required', 'confirmed', PasswordRule::defaults()],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Here we will attempt to reset the user's password. If it is successful we
|
|
||||||
// will update the password on an actual user model and persist it to the
|
|
||||||
// database. Otherwise we will parse the error and return the response.
|
|
||||||
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
|
|
||||||
$response = Password::broker()->reset($credentials, function (User $user, string $password) {
|
|
||||||
$user->password = Hash::make($password);
|
|
||||||
$user->setRememberToken(Str::random(60));
|
|
||||||
$user->save();
|
|
||||||
|
|
||||||
$this->loginService->login($user, auth()->getDefaultDriver());
|
|
||||||
});
|
|
||||||
|
|
||||||
// If the password was successfully reset, we will redirect the user back to
|
|
||||||
// the application's home authenticated view. If there is an error we can
|
|
||||||
// redirect them back to where they came from with their error message.
|
|
||||||
return $response === Password::PASSWORD_RESET
|
|
||||||
? $this->sendResetResponse()
|
|
||||||
: $this->sendResetFailedResponse($request, $response, $request->get('token'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the response for a successful password reset.
|
|
||||||
*/
|
|
||||||
protected function sendResetResponse(): RedirectResponse
|
|
||||||
{
|
|
||||||
$this->showSuccessNotification(trans('auth.reset_password_success'));
|
|
||||||
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user());
|
|
||||||
|
|
||||||
return redirect('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the response for a failed password reset.
|
|
||||||
*/
|
|
||||||
protected function sendResetFailedResponse(Request $request, string $response, string $token): RedirectResponse
|
|
||||||
{
|
|
||||||
// We show invalid users as invalid tokens as to not leak what
|
|
||||||
// users may exist in the system.
|
|
||||||
if ($response === Password::INVALID_USER) {
|
|
||||||
$response = Password::INVALID_TOKEN;
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect("/password/reset/{$token}")
|
|
||||||
->withInput($request->only('email'))
|
|
||||||
->withErrors(['email' => trans($response)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Controllers;
|
|
||||||
|
|
||||||
use BookStack\Access\Saml2Service;
|
|
||||||
use BookStack\Http\Controller;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class Saml2Controller extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected Saml2Service $samlService
|
|
||||||
) {
|
|
||||||
$this->middleware('guard:saml2');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the login flow via SAML2.
|
|
||||||
*/
|
|
||||||
public function login()
|
|
||||||
{
|
|
||||||
$loginDetails = $this->samlService->login();
|
|
||||||
session()->flash('saml2_request_id', $loginDetails['id']);
|
|
||||||
|
|
||||||
return redirect($loginDetails['url']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the logout flow via SAML2.
|
|
||||||
*/
|
|
||||||
public function logout()
|
|
||||||
{
|
|
||||||
$user = user();
|
|
||||||
if ($user->isGuest()) {
|
|
||||||
return redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
$logoutDetails = $this->samlService->logout($user);
|
|
||||||
|
|
||||||
if ($logoutDetails['id']) {
|
|
||||||
session()->flash('saml2_logout_request_id', $logoutDetails['id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect($logoutDetails['url']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Get the metadata for this SAML2 service provider.
|
|
||||||
*/
|
|
||||||
public function metadata()
|
|
||||||
{
|
|
||||||
$metaData = $this->samlService->metadata();
|
|
||||||
|
|
||||||
return response()->make($metaData, 200, [
|
|
||||||
'Content-Type' => 'text/xml',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single logout service.
|
|
||||||
* Handle logout requests and responses.
|
|
||||||
*/
|
|
||||||
public function sls()
|
|
||||||
{
|
|
||||||
$requestId = session()->pull('saml2_logout_request_id', null);
|
|
||||||
$redirect = $this->samlService->processSlsResponse($requestId);
|
|
||||||
|
|
||||||
return redirect($redirect);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assertion Consumer Service start URL. Takes the SAMLResponse from the IDP.
|
|
||||||
* Due to being an external POST request, we likely won't have context of the
|
|
||||||
* current user session due to lax cookies. To work around this we store the
|
|
||||||
* SAMLResponse data and redirect to the processAcs endpoint for the actual
|
|
||||||
* processing of the request with proper context of the user session.
|
|
||||||
*/
|
|
||||||
public function startAcs(Request $request)
|
|
||||||
{
|
|
||||||
$samlResponse = $request->get('SAMLResponse', null);
|
|
||||||
|
|
||||||
if (empty($samlResponse)) {
|
|
||||||
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
|
|
||||||
|
|
||||||
return redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
$acsId = Str::random(16);
|
|
||||||
$cacheKey = 'saml2_acs:' . $acsId;
|
|
||||||
cache()->set($cacheKey, encrypt($samlResponse), 10);
|
|
||||||
|
|
||||||
return redirect()->guest('/saml2/acs?id=' . $acsId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assertion Consumer Service process endpoint.
|
|
||||||
* Processes the SAML response from the IDP with context of the current session.
|
|
||||||
* Takes the SAML request from the cache, added by the startAcs method above.
|
|
||||||
*/
|
|
||||||
public function processAcs(Request $request)
|
|
||||||
{
|
|
||||||
$acsId = $request->get('id', null);
|
|
||||||
$cacheKey = 'saml2_acs:' . $acsId;
|
|
||||||
$samlResponse = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$samlResponse = decrypt(cache()->pull($cacheKey));
|
|
||||||
} catch (\Exception $exception) {
|
|
||||||
}
|
|
||||||
$requestId = session()->pull('saml2_request_id', null);
|
|
||||||
|
|
||||||
if (empty($acsId) || empty($samlResponse)) {
|
|
||||||
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
|
|
||||||
|
|
||||||
return redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->samlService->processAcsResponse($requestId, $samlResponse);
|
|
||||||
if (is_null($user)) {
|
|
||||||
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
|
|
||||||
|
|
||||||
return redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->intended();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Controllers;
|
|
||||||
|
|
||||||
use Illuminate\Cache\RateLimiter;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
trait ThrottlesLogins
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Determine if the user has too many failed login attempts.
|
|
||||||
*/
|
|
||||||
protected function hasTooManyLoginAttempts(Request $request): bool
|
|
||||||
{
|
|
||||||
return $this->limiter()->tooManyAttempts(
|
|
||||||
$this->throttleKey($request),
|
|
||||||
$this->maxAttempts()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Increment the login attempts for the user.
|
|
||||||
*/
|
|
||||||
protected function incrementLoginAttempts(Request $request): void
|
|
||||||
{
|
|
||||||
$this->limiter()->hit(
|
|
||||||
$this->throttleKey($request),
|
|
||||||
$this->decayMinutes() * 60
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redirect the user after determining they are locked out.
|
|
||||||
* @throws ValidationException
|
|
||||||
*/
|
|
||||||
protected function sendLockoutResponse(Request $request): \Symfony\Component\HttpFoundation\Response
|
|
||||||
{
|
|
||||||
$seconds = $this->limiter()->availableIn(
|
|
||||||
$this->throttleKey($request)
|
|
||||||
);
|
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
$this->username() => [trans('auth.throttle', [
|
|
||||||
'seconds' => $seconds,
|
|
||||||
'minutes' => ceil($seconds / 60),
|
|
||||||
])],
|
|
||||||
])->status(Response::HTTP_TOO_MANY_REQUESTS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the login locks for the given user credentials.
|
|
||||||
*/
|
|
||||||
protected function clearLoginAttempts(Request $request): void
|
|
||||||
{
|
|
||||||
$this->limiter()->clear($this->throttleKey($request));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the throttle key for the given request.
|
|
||||||
*/
|
|
||||||
protected function throttleKey(Request $request): string
|
|
||||||
{
|
|
||||||
return Str::transliterate(Str::lower($request->input($this->username())) . '|' . $request->ip());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the rate limiter instance.
|
|
||||||
*/
|
|
||||||
protected function limiter(): RateLimiter
|
|
||||||
{
|
|
||||||
return app()->make(RateLimiter::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the maximum number of attempts to allow.
|
|
||||||
*/
|
|
||||||
public function maxAttempts(): int
|
|
||||||
{
|
|
||||||
return 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the number of minutes to throttle for.
|
|
||||||
*/
|
|
||||||
public function decayMinutes(): int
|
|
||||||
{
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access;
|
|
||||||
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
|
||||||
use Illuminate\Contracts\Auth\UserProvider;
|
|
||||||
|
|
||||||
class ExternalBaseUserProvider implements UserProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Retrieve a user by their unique identifier.
|
|
||||||
*/
|
|
||||||
public function retrieveById(mixed $identifier): ?Authenticatable
|
|
||||||
{
|
|
||||||
return User::query()->find($identifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a user by their unique identifier and "remember me" token.
|
|
||||||
*
|
|
||||||
* @param string $token
|
|
||||||
*/
|
|
||||||
public function retrieveByToken(mixed $identifier, $token): null
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the "remember me" token for the given user in storage.
|
|
||||||
*
|
|
||||||
* @param Authenticatable $user
|
|
||||||
* @param string $token
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function updateRememberToken(Authenticatable $user, $token)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a user by the given credentials.
|
|
||||||
*/
|
|
||||||
public function retrieveByCredentials(array $credentials): ?Authenticatable
|
|
||||||
{
|
|
||||||
return User::query()
|
|
||||||
->where('external_auth_id', $credentials['external_auth_id'])
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a user against the given credentials.
|
|
||||||
*/
|
|
||||||
public function validateCredentials(Authenticatable $user, array $credentials): bool
|
|
||||||
{
|
|
||||||
// Should be done in the guard.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
|
|
||||||
{
|
|
||||||
// No action to perform, any passwords are external in the auth system
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Guards;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* External Auth Session Guard.
|
|
||||||
*
|
|
||||||
* The login process for external auth (SAML2/OIDC) is async in nature, meaning it does not fit very well
|
|
||||||
* into the default laravel 'Guard' auth flow. Instead, most of the logic is done via the relevant
|
|
||||||
* controller and services. This class provides a safer, thin version of SessionGuard.
|
|
||||||
*/
|
|
||||||
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Validate a user's credentials.
|
|
||||||
*/
|
|
||||||
public function validate(array $credentials = []): bool
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to authenticate a user using the given credentials.
|
|
||||||
*
|
|
||||||
* @param bool $remember
|
|
||||||
*/
|
|
||||||
public function attempt(array $credentials = [], $remember = false): bool
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class Ldap
|
|
||||||
* An object-orientated thin abstraction wrapper for common PHP LDAP functions.
|
|
||||||
* Allows the standard LDAP functions to be mocked for testing.
|
|
||||||
*/
|
|
||||||
class Ldap
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Connect to an LDAP server.
|
|
||||||
*
|
|
||||||
* @return resource|\LDAP\Connection|false
|
|
||||||
*/
|
|
||||||
public function connect(string $hostName)
|
|
||||||
{
|
|
||||||
return ldap_connect($hostName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the value of an LDAP option for the given connection.
|
|
||||||
*
|
|
||||||
* @param resource|\LDAP\Connection|null $ldapConnection
|
|
||||||
*/
|
|
||||||
public function setOption($ldapConnection, int $option, mixed $value): bool
|
|
||||||
{
|
|
||||||
return ldap_set_option($ldapConnection, $option, $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start TLS on the given LDAP connection.
|
|
||||||
*/
|
|
||||||
public function startTls($ldapConnection): bool
|
|
||||||
{
|
|
||||||
return ldap_start_tls($ldapConnection);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the version number for the given LDAP connection.
|
|
||||||
*
|
|
||||||
* @param resource|\LDAP\Connection $ldapConnection
|
|
||||||
*/
|
|
||||||
public function setVersion($ldapConnection, int $version): bool
|
|
||||||
{
|
|
||||||
return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search LDAP tree using the provided filter.
|
|
||||||
*
|
|
||||||
* @param resource|\LDAP\Connection $ldapConnection
|
|
||||||
*
|
|
||||||
* @return \LDAP\Result|array|false
|
|
||||||
*/
|
|
||||||
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = [])
|
|
||||||
{
|
|
||||||
return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read an entry from the LDAP tree.
|
|
||||||
*
|
|
||||||
* @param resource|\Ldap\Connection $ldapConnection
|
|
||||||
*
|
|
||||||
* @return \LDAP\Result|array|false
|
|
||||||
*/
|
|
||||||
public function read($ldapConnection, string $baseDn, string $filter, array $attributes = [])
|
|
||||||
{
|
|
||||||
return ldap_read($ldapConnection, $baseDn, $filter, $attributes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get entries from an LDAP search result.
|
|
||||||
*
|
|
||||||
* @param resource|\LDAP\Connection $ldapConnection
|
|
||||||
* @param resource|\LDAP\Result $ldapSearchResult
|
|
||||||
*/
|
|
||||||
public function getEntries($ldapConnection, $ldapSearchResult): array|false
|
|
||||||
{
|
|
||||||
return ldap_get_entries($ldapConnection, $ldapSearchResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search and get entries immediately.
|
|
||||||
*
|
|
||||||
* @param resource|\LDAP\Connection $ldapConnection
|
|
||||||
*/
|
|
||||||
public function searchAndGetEntries($ldapConnection, string $baseDn, string $filter, array $attributes = []): array|false
|
|
||||||
{
|
|
||||||
$search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
|
|
||||||
|
|
||||||
return $this->getEntries($ldapConnection, $search);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bind to LDAP directory.
|
|
||||||
*
|
|
||||||
* @param resource|\LDAP\Connection $ldapConnection
|
|
||||||
*/
|
|
||||||
public function bind($ldapConnection, ?string $bindRdn = null, ?string $bindPassword = null): bool
|
|
||||||
{
|
|
||||||
return ldap_bind($ldapConnection, $bindRdn, $bindPassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Explode an LDAP dn string into an array of components.
|
|
||||||
*/
|
|
||||||
public function explodeDn(string $dn, int $withAttrib): array|false
|
|
||||||
{
|
|
||||||
return ldap_explode_dn($dn, $withAttrib);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape a string for use in an LDAP filter.
|
|
||||||
*/
|
|
||||||
public function escape(string $value, string $ignore = '', int $flags = 0): string
|
|
||||||
{
|
|
||||||
return ldap_escape($value, $ignore, $flags);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Mfa;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
|
|
||||||
class TotpValidationRule implements ValidationRule
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Create a new rule instance.
|
|
||||||
* Takes the TOTP secret that must be system provided, not user provided.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
protected string $secret,
|
|
||||||
protected TotpService $totpService,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
|
||||||
{
|
|
||||||
$passes = $this->totpService->verifyCode($value, $this->secret);
|
|
||||||
if (!$passes) {
|
|
||||||
$fail(trans('validation.totp'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Notifications;
|
|
||||||
|
|
||||||
use BookStack\App\MailNotification;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
|
||||||
|
|
||||||
class ConfirmEmailNotification extends MailNotification
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $token
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toMail(User $notifiable): MailMessage
|
|
||||||
{
|
|
||||||
$appName = ['appName' => setting('app-name')];
|
|
||||||
|
|
||||||
return $this->newMailMessage()
|
|
||||||
->subject(trans('auth.email_confirm_subject', $appName))
|
|
||||||
->greeting(trans('auth.email_confirm_greeting', $appName))
|
|
||||||
->line(trans('auth.email_confirm_text'))
|
|
||||||
->action(trans('auth.email_confirm_action'), url('/register/confirm/' . $this->token));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Notifications;
|
|
||||||
|
|
||||||
use BookStack\App\MailNotification;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
|
||||||
|
|
||||||
class ResetPasswordNotification extends MailNotification
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $token
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toMail(User $notifiable): MailMessage
|
|
||||||
{
|
|
||||||
return $this->newMailMessage()
|
|
||||||
->subject(trans('auth.email_reset_subject', ['appName' => setting('app-name')]))
|
|
||||||
->line(trans('auth.email_reset_text'))
|
|
||||||
->action(trans('auth.reset_password'), url('password/reset/' . $this->token))
|
|
||||||
->line(trans('auth.email_reset_not_requested'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Notifications;
|
|
||||||
|
|
||||||
use BookStack\App\MailNotification;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
|
||||||
|
|
||||||
class UserInviteNotification extends MailNotification
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $token
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toMail(User $notifiable): MailMessage
|
|
||||||
{
|
|
||||||
$appName = ['appName' => setting('app-name')];
|
|
||||||
$locale = $notifiable->getLocale();
|
|
||||||
|
|
||||||
return $this->newMailMessage($locale)
|
|
||||||
->subject($locale->trans('auth.user_invite_email_subject', $appName))
|
|
||||||
->greeting($locale->trans('auth.user_invite_email_greeting', $appName))
|
|
||||||
->line($locale->trans('auth.user_invite_email_text'))
|
|
||||||
->action($locale->trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use League\OAuth2\Client\Token\AccessToken;
|
|
||||||
|
|
||||||
class OidcAccessToken extends AccessToken
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Constructs an access token.
|
|
||||||
*
|
|
||||||
* @param array $options An array of options returned by the service provider
|
|
||||||
* in the access token request. The `access_token` option is required.
|
|
||||||
*
|
|
||||||
* @throws InvalidArgumentException if `access_token` is not provided in `$options`.
|
|
||||||
*/
|
|
||||||
public function __construct(array $options = [])
|
|
||||||
{
|
|
||||||
parent::__construct($options);
|
|
||||||
$this->validate($options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate this access token response for OIDC.
|
|
||||||
* As per https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
|
|
||||||
*/
|
|
||||||
private function validate(array $options): void
|
|
||||||
{
|
|
||||||
// access_token: REQUIRED. Access Token for the UserInfo Endpoint.
|
|
||||||
// Performed on the extended class
|
|
||||||
|
|
||||||
// token_type: REQUIRED. OAuth 2.0 Token Type value. The value MUST be Bearer, as specified in OAuth 2.0
|
|
||||||
// Bearer Token Usage [RFC6750], for Clients using this subset.
|
|
||||||
// Note that the token_type value is case-insensitive.
|
|
||||||
if (strtolower(($options['token_type'] ?? '')) !== 'bearer') {
|
|
||||||
throw new InvalidArgumentException('The response token type MUST be "Bearer"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// id_token: REQUIRED. ID Token.
|
|
||||||
if (empty($options['id_token'])) {
|
|
||||||
throw new InvalidArgumentException('An "id_token" property must be provided');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the id token value from this access token response.
|
|
||||||
*/
|
|
||||||
public function getIdToken(): string
|
|
||||||
{
|
|
||||||
return $this->getValues()['id_token'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
class OidcException extends Exception
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
class OidcIdToken extends OidcJwtWithClaims implements ProvidesClaims
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Validate all possible parts of the id token.
|
|
||||||
*
|
|
||||||
* @throws OidcInvalidTokenException
|
|
||||||
*/
|
|
||||||
public function validate(string $clientId): bool
|
|
||||||
{
|
|
||||||
parent::validateCommonTokenDetails($clientId);
|
|
||||||
$this->validateTokenClaims($clientId);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the claims of the token.
|
|
||||||
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
|
|
||||||
*
|
|
||||||
* @throws OidcInvalidTokenException
|
|
||||||
*/
|
|
||||||
protected function validateTokenClaims(string $clientId): void
|
|
||||||
{
|
|
||||||
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
|
|
||||||
// MUST exactly match the value of the iss (issuer) Claim.
|
|
||||||
// Already done in parent.
|
|
||||||
|
|
||||||
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
|
|
||||||
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
|
|
||||||
// if the ID Token does not list the Client as a valid audience, or if it contains additional
|
|
||||||
// audiences not trusted by the Client.
|
|
||||||
// Partially done in parent.
|
|
||||||
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
|
|
||||||
if (count($aud) !== 1) {
|
|
||||||
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
|
|
||||||
// NOTE: Addressed by enforcing a count of 1 above.
|
|
||||||
|
|
||||||
// 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id
|
|
||||||
// is the Claim Value.
|
|
||||||
if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) {
|
|
||||||
throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. The current time MUST be before the time represented by the exp Claim
|
|
||||||
// (possibly allowing for some small leeway to account for clock skew).
|
|
||||||
if (empty($this->payload['exp'])) {
|
|
||||||
throw new OidcInvalidTokenException('Missing token expiration time value');
|
|
||||||
}
|
|
||||||
|
|
||||||
$skewSeconds = 120;
|
|
||||||
$now = time();
|
|
||||||
if ($now >= (intval($this->payload['exp']) + $skewSeconds)) {
|
|
||||||
throw new OidcInvalidTokenException('Token has expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. The iat Claim can be used to reject tokens that were issued too far away from the current time,
|
|
||||||
// limiting the amount of time that nonces need to be stored to prevent attacks.
|
|
||||||
// The acceptable range is Client specific.
|
|
||||||
if (empty($this->payload['iat'])) {
|
|
||||||
throw new OidcInvalidTokenException('Missing token issued at time value');
|
|
||||||
}
|
|
||||||
|
|
||||||
$dayAgo = time() - 86400;
|
|
||||||
$iat = intval($this->payload['iat']);
|
|
||||||
if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) {
|
|
||||||
throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate.
|
|
||||||
// The meaning and processing of acr Claim Values is out of scope for this document.
|
|
||||||
// NOTE: Not used for our case here. acr is not requested.
|
|
||||||
|
|
||||||
// 8. When a max_age request is made, the Client SHOULD check the auth_time Claim value and request
|
|
||||||
// re-authentication if it determines too much time has elapsed since the last End-User authentication.
|
|
||||||
// NOTE: Not used for our case here. A max_age request is not made.
|
|
||||||
|
|
||||||
// Custom: Ensure the "sub" (Subject) Claim exists and has a value.
|
|
||||||
if (empty($this->payload['sub'])) {
|
|
||||||
throw new OidcInvalidTokenException('Missing token subject value');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
class OidcInvalidKeyException extends \Exception
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
class OidcInvalidTokenException extends Exception
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
class OidcIssuerDiscoveryException extends Exception
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
use phpseclib3\Crypt\Common\PublicKey;
|
|
||||||
use phpseclib3\Crypt\PublicKeyLoader;
|
|
||||||
use phpseclib3\Crypt\RSA;
|
|
||||||
use phpseclib3\Math\BigInteger;
|
|
||||||
|
|
||||||
class OidcJwtSigningKey
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var PublicKey
|
|
||||||
*/
|
|
||||||
protected $key;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Can be created either from a JWK parameter array or local file path to load a certificate from.
|
|
||||||
* Examples:
|
|
||||||
* 'file:///var/www/cert.pem'
|
|
||||||
* ['kty' => 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'].
|
|
||||||
*
|
|
||||||
* @param array|string $jwkOrKeyPath
|
|
||||||
*
|
|
||||||
* @throws OidcInvalidKeyException
|
|
||||||
*/
|
|
||||||
public function __construct($jwkOrKeyPath)
|
|
||||||
{
|
|
||||||
if (is_array($jwkOrKeyPath)) {
|
|
||||||
$this->loadFromJwkArray($jwkOrKeyPath);
|
|
||||||
} elseif (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) {
|
|
||||||
$this->loadFromPath($jwkOrKeyPath);
|
|
||||||
} else {
|
|
||||||
throw new OidcInvalidKeyException('Unexpected type of key value provided');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws OidcInvalidKeyException
|
|
||||||
*/
|
|
||||||
protected function loadFromPath(string $path)
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$key = PublicKeyLoader::load(
|
|
||||||
file_get_contents($path)
|
|
||||||
);
|
|
||||||
} catch (\Exception $exception) {
|
|
||||||
throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$key instanceof RSA) {
|
|
||||||
throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws OidcInvalidKeyException
|
|
||||||
*/
|
|
||||||
protected function loadFromJwkArray(array $jwk)
|
|
||||||
{
|
|
||||||
// 'alg' is optional for a JWK, but we will still attempt to validate if
|
|
||||||
// it exists otherwise presume it will be compatible.
|
|
||||||
$alg = $jwk['alg'] ?? null;
|
|
||||||
if ($jwk['kty'] !== 'RSA' || !(is_null($alg) || $alg === 'RS256')) {
|
|
||||||
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// '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') {
|
|
||||||
throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($jwk['e'])) {
|
|
||||||
throw new OidcInvalidKeyException('An "e" parameter on the provided key is expected');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($jwk['n'])) {
|
|
||||||
throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected');
|
|
||||||
}
|
|
||||||
|
|
||||||
$n = strtr($jwk['n'] ?? '', '-_', '+/');
|
|
||||||
|
|
||||||
try {
|
|
||||||
$key = PublicKeyLoader::load([
|
|
||||||
'e' => new BigInteger(base64_decode($jwk['e']), 256),
|
|
||||||
'n' => new BigInteger(base64_decode($n), 256),
|
|
||||||
]);
|
|
||||||
} catch (\Exception $exception) {
|
|
||||||
throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$key instanceof RSA) {
|
|
||||||
throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use this key to sign the given content and return the signature.
|
|
||||||
*/
|
|
||||||
public function verify(string $content, string $signature): bool
|
|
||||||
{
|
|
||||||
return $this->key->verify($content, $signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert the key to a PEM encoded key string.
|
|
||||||
*/
|
|
||||||
public function toPem(): string
|
|
||||||
{
|
|
||||||
return $this->key->toString('PKCS8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
class OidcJwtWithClaims implements ProvidesClaims
|
|
||||||
{
|
|
||||||
protected array $header;
|
|
||||||
protected array $payload;
|
|
||||||
protected string $signature;
|
|
||||||
protected string $issuer;
|
|
||||||
protected array $tokenParts = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array[]|string[]
|
|
||||||
*/
|
|
||||||
protected array $keys;
|
|
||||||
|
|
||||||
public function __construct(string $token, string $issuer, array $keys)
|
|
||||||
{
|
|
||||||
$this->keys = $keys;
|
|
||||||
$this->issuer = $issuer;
|
|
||||||
$this->parse($token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the token content into its components.
|
|
||||||
*/
|
|
||||||
protected function parse(string $token): void
|
|
||||||
{
|
|
||||||
$this->tokenParts = explode('.', $token);
|
|
||||||
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
|
|
||||||
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
|
|
||||||
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a Base64-JSON encoded token part.
|
|
||||||
* Returns the data as a key-value array or empty array upon error.
|
|
||||||
*/
|
|
||||||
protected function parseEncodedTokenPart(string $part): array
|
|
||||||
{
|
|
||||||
$json = $this->base64UrlDecode($part) ?: '{}';
|
|
||||||
$decoded = json_decode($json, true);
|
|
||||||
|
|
||||||
return is_array($decoded) ? $decoded : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base64URL decode. Needs some character conversions to be compatible
|
|
||||||
* with PHP's default base64 handling.
|
|
||||||
*/
|
|
||||||
protected function base64UrlDecode(string $encoded): string
|
|
||||||
{
|
|
||||||
return base64_decode(strtr($encoded, '-_', '+/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate common parts of OIDC JWT tokens.
|
|
||||||
*
|
|
||||||
* @throws OidcInvalidTokenException
|
|
||||||
*/
|
|
||||||
public function validateCommonTokenDetails(string $clientId): bool
|
|
||||||
{
|
|
||||||
$this->validateTokenStructure();
|
|
||||||
$this->validateTokenSignature();
|
|
||||||
$this->validateCommonClaims($clientId);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a specific claim from this token.
|
|
||||||
* Returns null if it is null or does not exist.
|
|
||||||
*/
|
|
||||||
public function getClaim(string $claim): mixed
|
|
||||||
{
|
|
||||||
return $this->payload[$claim] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all returned claims within the token.
|
|
||||||
*/
|
|
||||||
public function getAllClaims(): array
|
|
||||||
{
|
|
||||||
return $this->payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the existing claim data of this token with that provided.
|
|
||||||
*/
|
|
||||||
public function replaceClaims(array $claims): void
|
|
||||||
{
|
|
||||||
$this->payload = $claims;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the structure of the given token and ensure we have the required pieces.
|
|
||||||
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
|
|
||||||
*
|
|
||||||
* @throws OidcInvalidTokenException
|
|
||||||
*/
|
|
||||||
protected function validateTokenStructure(): void
|
|
||||||
{
|
|
||||||
foreach (['header', 'payload'] as $prop) {
|
|
||||||
if (empty($this->$prop) || !is_array($this->$prop)) {
|
|
||||||
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($this->signature) || !is_string($this->signature)) {
|
|
||||||
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the signature of the given token and ensure it validates against the provided key.
|
|
||||||
*
|
|
||||||
* @throws OidcInvalidTokenException
|
|
||||||
*/
|
|
||||||
protected function validateTokenSignature(): void
|
|
||||||
{
|
|
||||||
if ($this->header['alg'] !== 'RS256') {
|
|
||||||
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$parsedKeys = array_map(function ($key) {
|
|
||||||
try {
|
|
||||||
return new OidcJwtSigningKey($key);
|
|
||||||
} catch (OidcInvalidKeyException $e) {
|
|
||||||
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}, $this->keys);
|
|
||||||
|
|
||||||
$parsedKeys = array_filter($parsedKeys);
|
|
||||||
|
|
||||||
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
|
|
||||||
/** @var OidcJwtSigningKey $parsedKey */
|
|
||||||
foreach ($parsedKeys as $parsedKey) {
|
|
||||||
if ($parsedKey->verify($contentToSign, $this->signature)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate common claims for OIDC JWT tokens.
|
|
||||||
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation
|
|
||||||
* and https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
|
|
||||||
*
|
|
||||||
* @throws OidcInvalidTokenException
|
|
||||||
*/
|
|
||||||
protected function validateCommonClaims(string $clientId): void
|
|
||||||
{
|
|
||||||
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
|
|
||||||
// MUST exactly match the value of the iss (issuer) Claim.
|
|
||||||
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
|
|
||||||
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
|
|
||||||
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
|
|
||||||
// if the ID Token does not list the Client as a valid audience.
|
|
||||||
if (empty($this->payload['aud'])) {
|
|
||||||
throw new OidcInvalidTokenException('Missing token audience value');
|
|
||||||
}
|
|
||||||
|
|
||||||
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
|
|
||||||
if (!in_array($clientId, $aud, true)) {
|
|
||||||
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
use League\OAuth2\Client\Grant\AbstractGrant;
|
|
||||||
use League\OAuth2\Client\Provider\AbstractProvider;
|
|
||||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
|
||||||
use League\OAuth2\Client\Provider\GenericResourceOwner;
|
|
||||||
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
|
|
||||||
use League\OAuth2\Client\Token\AccessToken;
|
|
||||||
use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extended OAuth2Provider for using with OIDC.
|
|
||||||
* Credit to the https://github.com/steverhoades/oauth2-openid-connect-client
|
|
||||||
* project for the idea of extending a League\OAuth2 client for this use-case.
|
|
||||||
*/
|
|
||||||
class OidcOAuthProvider extends AbstractProvider
|
|
||||||
{
|
|
||||||
use BearerAuthorizationTrait;
|
|
||||||
|
|
||||||
protected string $authorizationEndpoint;
|
|
||||||
protected string $tokenEndpoint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scopes to use for the OIDC authorization call.
|
|
||||||
*/
|
|
||||||
protected array $scopes = ['openid', 'profile', 'email'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the base URL for authorizing a client.
|
|
||||||
*/
|
|
||||||
public function getBaseAuthorizationUrl(): string
|
|
||||||
{
|
|
||||||
return $this->authorizationEndpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the base URL for requesting an access token.
|
|
||||||
*/
|
|
||||||
public function getBaseAccessTokenUrl(array $params): string
|
|
||||||
{
|
|
||||||
return $this->tokenEndpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the URL for requesting the resource owner's details.
|
|
||||||
*/
|
|
||||||
public function getResourceOwnerDetailsUrl(AccessToken $token): string
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add another 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.
|
|
||||||
*
|
|
||||||
* This should only be the scopes that are required to request the details
|
|
||||||
* of the resource owner, rather than all the available scopes.
|
|
||||||
*/
|
|
||||||
protected function getDefaultScopes(): array
|
|
||||||
{
|
|
||||||
return $this->scopes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the string that should be used to separate scopes when building
|
|
||||||
* the URL for requesting an access token.
|
|
||||||
*/
|
|
||||||
protected function getScopeSeparator(): string
|
|
||||||
{
|
|
||||||
return ' ';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks a provider response for errors.
|
|
||||||
* @throws IdentityProviderException
|
|
||||||
*/
|
|
||||||
protected function checkResponse(ResponseInterface $response, $data): void
|
|
||||||
{
|
|
||||||
if ($response->getStatusCode() >= 400 || isset($data['error'])) {
|
|
||||||
throw new IdentityProviderException(
|
|
||||||
$data['error'] ?? $response->getReasonPhrase(),
|
|
||||||
$response->getStatusCode(),
|
|
||||||
(string) $response->getBody()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a resource owner object from a successful resource owner
|
|
||||||
* details request.
|
|
||||||
*/
|
|
||||||
protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface
|
|
||||||
{
|
|
||||||
return new GenericResourceOwner($response, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an access token from a response.
|
|
||||||
*
|
|
||||||
* The grant that was used to fetch the response can be used to provide
|
|
||||||
* additional context.
|
|
||||||
*/
|
|
||||||
protected function createAccessToken(array $response, AbstractGrant $grant): OidcAccessToken
|
|
||||||
{
|
|
||||||
return new OidcAccessToken($response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the method used for PKCE code verifier hashing, which is passed
|
|
||||||
* in the "code_challenge_method" parameter in the authorization request.
|
|
||||||
*/
|
|
||||||
protected function getPkceMethod(): string
|
|
||||||
{
|
|
||||||
return static::PKCE_METHOD_S256;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
use GuzzleHttp\Psr7\Request;
|
|
||||||
use Illuminate\Contracts\Cache\Repository;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use Psr\Http\Client\ClientExceptionInterface;
|
|
||||||
use Psr\Http\Client\ClientInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OpenIdConnectProviderSettings
|
|
||||||
* Acts as a DTO for settings used within the oidc request and token handling.
|
|
||||||
* Performs auto-discovery upon request.
|
|
||||||
*/
|
|
||||||
class OidcProviderSettings
|
|
||||||
{
|
|
||||||
public string $issuer;
|
|
||||||
public string $clientId;
|
|
||||||
public string $clientSecret;
|
|
||||||
public ?string $authorizationEndpoint;
|
|
||||||
public ?string $tokenEndpoint;
|
|
||||||
public ?string $endSessionEndpoint;
|
|
||||||
public ?string $userinfoEndpoint;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string[]|array[]
|
|
||||||
*/
|
|
||||||
public ?array $keys = [];
|
|
||||||
|
|
||||||
public function __construct(array $settings)
|
|
||||||
{
|
|
||||||
$this->applySettingsFromArray($settings);
|
|
||||||
$this->validateInitial();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply an array of settings to populate setting properties within this class.
|
|
||||||
*/
|
|
||||||
protected function applySettingsFromArray(array $settingsArray): void
|
|
||||||
{
|
|
||||||
foreach ($settingsArray as $key => $value) {
|
|
||||||
if (property_exists($this, $key)) {
|
|
||||||
$this->$key = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate any core, required properties have been set.
|
|
||||||
*
|
|
||||||
* @throws InvalidArgumentException
|
|
||||||
*/
|
|
||||||
protected function validateInitial(): void
|
|
||||||
{
|
|
||||||
$required = ['clientId', 'clientSecret', 'issuer'];
|
|
||||||
foreach ($required as $prop) {
|
|
||||||
if (empty($this->$prop)) {
|
|
||||||
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!str_starts_with($this->issuer, 'https://')) {
|
|
||||||
throw new InvalidArgumentException('Issuer value must start with https://');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform a full validation on these settings.
|
|
||||||
*
|
|
||||||
* @throws InvalidArgumentException
|
|
||||||
*/
|
|
||||||
public function validate(): void
|
|
||||||
{
|
|
||||||
$this->validateInitial();
|
|
||||||
|
|
||||||
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
|
|
||||||
foreach ($required as $prop) {
|
|
||||||
if (empty($this->$prop)) {
|
|
||||||
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$endpointProperties = ['tokenEndpoint', 'authorizationEndpoint', 'userinfoEndpoint'];
|
|
||||||
foreach ($endpointProperties as $prop) {
|
|
||||||
if (is_string($this->$prop) && !str_starts_with($this->$prop, 'https://')) {
|
|
||||||
throw new InvalidArgumentException("Endpoint value for \"{$prop}\" must start with https://");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover and autoload settings from the configured issuer.
|
|
||||||
*
|
|
||||||
* @throws OidcIssuerDiscoveryException
|
|
||||||
*/
|
|
||||||
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$cacheKey = 'oidc-discovery::' . $this->issuer;
|
|
||||||
$discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function () use ($httpClient) {
|
|
||||||
return $this->loadSettingsFromIssuerDiscovery($httpClient);
|
|
||||||
});
|
|
||||||
$this->applySettingsFromArray($discoveredSettings);
|
|
||||||
} catch (ClientExceptionInterface $exception) {
|
|
||||||
throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws OidcIssuerDiscoveryException
|
|
||||||
* @throws ClientExceptionInterface
|
|
||||||
*/
|
|
||||||
protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array
|
|
||||||
{
|
|
||||||
$issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration';
|
|
||||||
$request = new Request('GET', $issuerUrl);
|
|
||||||
$response = $httpClient->sendRequest($request);
|
|
||||||
$result = json_decode($response->getBody()->getContents(), true);
|
|
||||||
|
|
||||||
if (empty($result) || !is_array($result)) {
|
|
||||||
throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result['issuer'] !== $this->issuer) {
|
|
||||||
throw new OidcIssuerDiscoveryException('Unexpected issuer value found on discovery response');
|
|
||||||
}
|
|
||||||
|
|
||||||
$discoveredSettings = [];
|
|
||||||
|
|
||||||
if (!empty($result['authorization_endpoint'])) {
|
|
||||||
$discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($result['token_endpoint'])) {
|
|
||||||
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($result['userinfo_endpoint'])) {
|
|
||||||
$discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($result['jwks_uri'])) {
|
|
||||||
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
|
|
||||||
$discoveredSettings['keys'] = $this->filterKeys($keys);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($result['end_session_endpoint'])) {
|
|
||||||
$discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $discoveredSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter the given JWK keys down to just those we support.
|
|
||||||
*/
|
|
||||||
protected function filterKeys(array $keys): array
|
|
||||||
{
|
|
||||||
return array_filter($keys, function (array $key) {
|
|
||||||
$alg = $key['alg'] ?? 'RS256';
|
|
||||||
$use = $key['use'] ?? 'sig';
|
|
||||||
|
|
||||||
return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return an array of jwks as PHP key=>value arrays.
|
|
||||||
*
|
|
||||||
* @throws ClientExceptionInterface
|
|
||||||
* @throws OidcIssuerDiscoveryException
|
|
||||||
*/
|
|
||||||
protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array
|
|
||||||
{
|
|
||||||
$request = new Request('GET', $uri);
|
|
||||||
$response = $httpClient->sendRequest($request);
|
|
||||||
$result = json_decode($response->getBody()->getContents(), true);
|
|
||||||
|
|
||||||
if (empty($result) || !is_array($result) || !isset($result['keys'])) {
|
|
||||||
throw new OidcIssuerDiscoveryException('Error reading keys from issuer jwks_uri');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result['keys'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the settings needed by an OAuth provider, as a key=>value array.
|
|
||||||
*/
|
|
||||||
public function arrayForOAuthProvider(): array
|
|
||||||
{
|
|
||||||
$settingKeys = ['clientId', 'clientSecret', 'authorizationEndpoint', 'tokenEndpoint', 'userinfoEndpoint'];
|
|
||||||
$settings = [];
|
|
||||||
foreach ($settingKeys as $setting) {
|
|
||||||
$settings[$setting] = $this->$setting;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $settings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
use BookStack\Access\GroupSyncService;
|
|
||||||
use BookStack\Access\LoginService;
|
|
||||||
use BookStack\Access\RegistrationService;
|
|
||||||
use BookStack\Exceptions\JsonDebugException;
|
|
||||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
|
||||||
use BookStack\Exceptions\UserRegistrationException;
|
|
||||||
use BookStack\Facades\Theme;
|
|
||||||
use BookStack\Http\HttpRequestService;
|
|
||||||
use BookStack\Theming\ThemeEvents;
|
|
||||||
use BookStack\Uploads\UserAvatars;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
|
||||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class OpenIdConnectService
|
|
||||||
* Handles any app-specific OIDC tasks.
|
|
||||||
*/
|
|
||||||
class OidcService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected RegistrationService $registrationService,
|
|
||||||
protected LoginService $loginService,
|
|
||||||
protected HttpRequestService $http,
|
|
||||||
protected GroupSyncService $groupService,
|
|
||||||
protected UserAvatars $userAvatars
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiate an authorization flow.
|
|
||||||
* Provides back an authorize redirect URL, in addition to other
|
|
||||||
* details which may be required for the auth flow.
|
|
||||||
*
|
|
||||||
* @throws OidcException
|
|
||||||
*
|
|
||||||
* @return array{url: string, state: string}
|
|
||||||
*/
|
|
||||||
public function login(): array
|
|
||||||
{
|
|
||||||
$settings = $this->getProviderSettings();
|
|
||||||
$provider = $this->getProvider($settings);
|
|
||||||
|
|
||||||
$url = $provider->getAuthorizationUrl();
|
|
||||||
session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'url' => $url,
|
|
||||||
'state' => $provider->getState(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process the Authorization response from the authorization server and
|
|
||||||
* return the matching, or new if registration active, user matched to the
|
|
||||||
* authorization server. Throws if the user cannot be auth if not authenticated.
|
|
||||||
*
|
|
||||||
* @throws JsonDebugException
|
|
||||||
* @throws OidcException
|
|
||||||
* @throws StoppedAuthenticationException
|
|
||||||
* @throws IdentityProviderException
|
|
||||||
*/
|
|
||||||
public function processAuthorizeResponse(?string $authorizationCode): User
|
|
||||||
{
|
|
||||||
$settings = $this->getProviderSettings();
|
|
||||||
$provider = $this->getProvider($settings);
|
|
||||||
|
|
||||||
// Set PKCE code flashed at login
|
|
||||||
$pkceCode = session()->pull('oidc_pkce_code', '');
|
|
||||||
$provider->setPkceCode($pkceCode);
|
|
||||||
|
|
||||||
// Try to exchange authorization code for access token
|
|
||||||
$accessToken = $provider->getAccessToken('authorization_code', [
|
|
||||||
'code' => $authorizationCode,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->processAccessTokenCallback($accessToken, $settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws OidcException
|
|
||||||
*/
|
|
||||||
protected function getProviderSettings(): OidcProviderSettings
|
|
||||||
{
|
|
||||||
$config = $this->config();
|
|
||||||
$settings = new OidcProviderSettings([
|
|
||||||
'issuer' => $config['issuer'],
|
|
||||||
'clientId' => $config['client_id'],
|
|
||||||
'clientSecret' => $config['client_secret'],
|
|
||||||
'authorizationEndpoint' => $config['authorization_endpoint'],
|
|
||||||
'tokenEndpoint' => $config['token_endpoint'],
|
|
||||||
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
|
|
||||||
'userinfoEndpoint' => $config['userinfo_endpoint'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Use keys if configured
|
|
||||||
if (!empty($config['jwt_public_key'])) {
|
|
||||||
$settings->keys = [$config['jwt_public_key']];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run discovery
|
|
||||||
if ($config['discover'] ?? false) {
|
|
||||||
try {
|
|
||||||
$settings->discoverFromIssuer($this->http->buildClient(5), Cache::store(null), 15);
|
|
||||||
} catch (OidcIssuerDiscoveryException $exception) {
|
|
||||||
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent use of RP-initiated logout if specifically disabled
|
|
||||||
// Or force use of a URL if specifically set.
|
|
||||||
if ($config['end_session_endpoint'] === false) {
|
|
||||||
$settings->endSessionEndpoint = null;
|
|
||||||
} else if (is_string($config['end_session_endpoint'])) {
|
|
||||||
$settings->endSessionEndpoint = $config['end_session_endpoint'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings->validate();
|
|
||||||
|
|
||||||
return $settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the underlying OpenID Connect Provider.
|
|
||||||
*/
|
|
||||||
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
|
|
||||||
{
|
|
||||||
$provider = new OidcOAuthProvider([
|
|
||||||
...$settings->arrayForOAuthProvider(),
|
|
||||||
'redirectUri' => url('/oidc/callback'),
|
|
||||||
], [
|
|
||||||
'httpClient' => $this->http->buildClient(5),
|
|
||||||
'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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes a received access token for a user. Login the user when
|
|
||||||
* they exist, optionally registering them automatically.
|
|
||||||
*
|
|
||||||
* @throws OidcException
|
|
||||||
* @throws JsonDebugException
|
|
||||||
* @throws StoppedAuthenticationException
|
|
||||||
*/
|
|
||||||
protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
|
|
||||||
{
|
|
||||||
$idTokenText = $accessToken->getIdToken();
|
|
||||||
$idToken = new OidcIdToken(
|
|
||||||
$idTokenText,
|
|
||||||
$settings->issuer,
|
|
||||||
$settings->keys,
|
|
||||||
);
|
|
||||||
|
|
||||||
session()->put("oidc_id_token", $idTokenText);
|
|
||||||
|
|
||||||
$returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [
|
|
||||||
'access_token' => $accessToken->getToken(),
|
|
||||||
'expires_in' => $accessToken->getExpires(),
|
|
||||||
'refresh_token' => $accessToken->getRefreshToken(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!is_null($returnClaims)) {
|
|
||||||
$idToken->replaceClaims($returnClaims);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->config()['dump_user_details']) {
|
|
||||||
throw new JsonDebugException($idToken->getAllClaims());
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$idToken->validate($settings->clientId);
|
|
||||||
} catch (OidcInvalidTokenException $exception) {
|
|
||||||
throw new OidcException("ID token validation failed with error: {$exception->getMessage()}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$userDetails = $this->getUserDetailsFromToken($idToken, $accessToken, $settings);
|
|
||||||
if (empty($userDetails->email)) {
|
|
||||||
throw new OidcException(trans('errors.oidc_no_email_address'));
|
|
||||||
}
|
|
||||||
if (empty($userDetails->name)) {
|
|
||||||
$userDetails->name = $userDetails->externalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
$isLoggedIn = auth()->check();
|
|
||||||
if ($isLoggedIn) {
|
|
||||||
throw new OidcException(trans('errors.oidc_already_logged_in'));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$user = $this->registrationService->findOrRegister(
|
|
||||||
$userDetails->name,
|
|
||||||
$userDetails->email,
|
|
||||||
$userDetails->externalId
|
|
||||||
);
|
|
||||||
} catch (UserRegistrationException $exception) {
|
|
||||||
throw new OidcException($exception->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->config()['fetch_avatar'] && !$user->avatar()->exists() && $userDetails->picture) {
|
|
||||||
$this->userAvatars->assignToUserFromUrl($user, $userDetails->picture);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->shouldSyncGroups()) {
|
|
||||||
$detachExisting = $this->config()['remove_from_groups'];
|
|
||||||
$this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->loginService->login($user, 'oidc');
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws OidcException
|
|
||||||
*/
|
|
||||||
protected function getUserDetailsFromToken(OidcIdToken $idToken, OidcAccessToken $accessToken, OidcProviderSettings $settings): OidcUserDetails
|
|
||||||
{
|
|
||||||
$userDetails = new OidcUserDetails();
|
|
||||||
$userDetails->populate(
|
|
||||||
$idToken,
|
|
||||||
$this->config()['external_id_claim'],
|
|
||||||
$this->config()['display_name_claims'] ?? '',
|
|
||||||
$this->config()['groups_claim'] ?? ''
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!$userDetails->isFullyPopulated($this->shouldSyncGroups()) && !empty($settings->userinfoEndpoint)) {
|
|
||||||
$provider = $this->getProvider($settings);
|
|
||||||
$request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken());
|
|
||||||
$response = new OidcUserinfoResponse(
|
|
||||||
$provider->getResponse($request),
|
|
||||||
$settings->issuer,
|
|
||||||
$settings->keys,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response->validate($idToken->getClaim('sub'), $settings->clientId);
|
|
||||||
} catch (OidcInvalidTokenException $exception) {
|
|
||||||
throw new OidcException("Userinfo endpoint response validation failed with error: {$exception->getMessage()}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$userDetails->populate(
|
|
||||||
$response,
|
|
||||||
$this->config()['external_id_claim'],
|
|
||||||
$this->config()['display_name_claims'] ?? '',
|
|
||||||
$this->config()['groups_claim'] ?? ''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $userDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the OIDC config from the application.
|
|
||||||
*/
|
|
||||||
protected function config(): array
|
|
||||||
{
|
|
||||||
return config('oidc');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if groups should be synced.
|
|
||||||
*/
|
|
||||||
protected function shouldSyncGroups(): bool
|
|
||||||
{
|
|
||||||
return $this->config()['user_to_groups'] !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the RP-initiated logout flow if active, otherwise start a standard logout flow.
|
|
||||||
* Returns a post-app-logout redirect URL.
|
|
||||||
* Reference: https://openid.net/specs/openid-connect-rpinitiated-1_0.html
|
|
||||||
* @throws OidcException
|
|
||||||
*/
|
|
||||||
public function logout(): string
|
|
||||||
{
|
|
||||||
$oidcToken = session()->pull("oidc_id_token");
|
|
||||||
$defaultLogoutUrl = url($this->loginService->logout());
|
|
||||||
$oidcSettings = $this->getProviderSettings();
|
|
||||||
|
|
||||||
if (!$oidcSettings->endSessionEndpoint) {
|
|
||||||
return $defaultLogoutUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
$endpointParams = [
|
|
||||||
'id_token_hint' => $oidcToken,
|
|
||||||
'post_logout_redirect_uri' => $defaultLogoutUrl,
|
|
||||||
];
|
|
||||||
|
|
||||||
$joiner = str_contains($oidcSettings->endSessionEndpoint, '?') ? '&' : '?';
|
|
||||||
|
|
||||||
return $oidcSettings->endSessionEndpoint . $joiner . http_build_query($endpointParams);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
class OidcUserDetails
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?string $externalId = null,
|
|
||||||
public ?string $email = null,
|
|
||||||
public ?string $name = null,
|
|
||||||
public ?array $groups = null,
|
|
||||||
public ?string $picture = null,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the user details are fully populated for our usage.
|
|
||||||
*/
|
|
||||||
public function isFullyPopulated(bool $groupSyncActive): bool
|
|
||||||
{
|
|
||||||
$hasEmpty = empty($this->externalId)
|
|
||||||
|| empty($this->email)
|
|
||||||
|| empty($this->name)
|
|
||||||
|| ($groupSyncActive && $this->groups === null);
|
|
||||||
|
|
||||||
return !$hasEmpty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Populate user details from the given claim data.
|
|
||||||
*/
|
|
||||||
public function populate(
|
|
||||||
ProvidesClaims $claims,
|
|
||||||
string $idClaim,
|
|
||||||
string $displayNameClaims,
|
|
||||||
string $groupsClaim,
|
|
||||||
): void {
|
|
||||||
$this->externalId = $claims->getClaim($idClaim) ?? $this->externalId;
|
|
||||||
$this->email = $claims->getClaim('email') ?? $this->email;
|
|
||||||
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
|
|
||||||
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
|
|
||||||
$this->picture = static::getPicture($claims) ?: $this->picture;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $claims): string
|
|
||||||
{
|
|
||||||
$displayNameClaimParts = explode('|', $displayNameClaims);
|
|
||||||
|
|
||||||
$displayName = [];
|
|
||||||
foreach ($displayNameClaimParts as $claim) {
|
|
||||||
$component = $claims->getClaim(trim($claim)) ?? '';
|
|
||||||
if ($component !== '') {
|
|
||||||
$displayName[] = $component;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode(' ', $displayName);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $claims): ?array
|
|
||||||
{
|
|
||||||
if (empty($groupsClaim)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$groupsList = Arr::get($claims->getAllClaims(), $groupsClaim);
|
|
||||||
if (!is_array($groupsList)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_filter($groupsList, function ($val) {
|
|
||||||
return is_string($val);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function getPicture(ProvidesClaims $claims): ?string
|
|
||||||
{
|
|
||||||
$picture = $claims->getClaim('picture');
|
|
||||||
if (is_string($picture) && str_starts_with($picture, 'http')) {
|
|
||||||
return $picture;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
|
||||||
|
|
||||||
class OidcUserinfoResponse implements ProvidesClaims
|
|
||||||
{
|
|
||||||
protected array $claims = [];
|
|
||||||
protected ?OidcJwtWithClaims $jwt = null;
|
|
||||||
|
|
||||||
public function __construct(ResponseInterface $response, string $issuer, array $keys)
|
|
||||||
{
|
|
||||||
$contentTypeHeaderValue = $response->getHeader('Content-Type')[0] ?? '';
|
|
||||||
$contentType = strtolower(trim(explode(';', $contentTypeHeaderValue, 2)[0]));
|
|
||||||
|
|
||||||
if ($contentType === 'application/json') {
|
|
||||||
$this->claims = json_decode($response->getBody()->getContents(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($contentType === 'application/jwt') {
|
|
||||||
$this->jwt = new OidcJwtWithClaims($response->getBody()->getContents(), $issuer, $keys);
|
|
||||||
$this->claims = $this->jwt->getAllClaims();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws OidcInvalidTokenException
|
|
||||||
*/
|
|
||||||
public function validate(string $idTokenSub, string $clientId): bool
|
|
||||||
{
|
|
||||||
if (!is_null($this->jwt)) {
|
|
||||||
$this->jwt->validateCommonTokenDetails($clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$sub = $this->getClaim('sub');
|
|
||||||
|
|
||||||
// Spec: v1.0 5.3.2: The sub (subject) Claim MUST always be returned in the UserInfo Response.
|
|
||||||
if (!is_string($sub) || empty($sub)) {
|
|
||||||
throw new OidcInvalidTokenException("No valid subject value found in userinfo data");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spec: v1.0 5.3.2: The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token;
|
|
||||||
// if they do not match, the UserInfo Response values MUST NOT be used.
|
|
||||||
if ($idTokenSub !== $sub) {
|
|
||||||
throw new OidcInvalidTokenException("Subject value provided in the userinfo endpoint does not match the provided ID token value");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spec v1.0 5.3.4 Defines the following:
|
|
||||||
// Verify that the OP that responded was the intended OP through a TLS server certificate check, per RFC 6125 [RFC6125].
|
|
||||||
// This is effectively done as part of the HTTP request we're making through CURLOPT_SSL_VERIFYHOST on the request.
|
|
||||||
// If the Client has provided a userinfo_encrypted_response_alg parameter during Registration, decrypt the UserInfo Response using the keys specified during Registration.
|
|
||||||
// We don't currently support JWT encryption for OIDC
|
|
||||||
// If the response was signed, the Client SHOULD validate the signature according to JWS [JWS].
|
|
||||||
// This is done as part of the validateCommonClaims above.
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getClaim(string $claim): mixed
|
|
||||||
{
|
|
||||||
return $this->claims[$claim] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAllClaims(): array
|
|
||||||
{
|
|
||||||
return $this->claims;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
|
||||||
|
|
||||||
interface ProvidesClaims
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Fetch a specific claim.
|
|
||||||
* Returns null if it is null or does not exist.
|
|
||||||
*/
|
|
||||||
public function getClaim(string $claim): mixed;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all contained claims.
|
|
||||||
*/
|
|
||||||
public function getAllClaims(): array;
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access;
|
|
||||||
|
|
||||||
use BookStack\Exceptions\SocialDriverNotConfigured;
|
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
|
||||||
|
|
||||||
class SocialDriverManager
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The default built-in social drivers we support.
|
|
||||||
*
|
|
||||||
* @var string[]
|
|
||||||
*/
|
|
||||||
protected array $validDrivers = [
|
|
||||||
'google',
|
|
||||||
'github',
|
|
||||||
'facebook',
|
|
||||||
'slack',
|
|
||||||
'twitter',
|
|
||||||
'azure',
|
|
||||||
'okta',
|
|
||||||
'gitlab',
|
|
||||||
'twitch',
|
|
||||||
'discord',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callbacks to run when configuring a social driver
|
|
||||||
* for an initial redirect action.
|
|
||||||
* Array is keyed by social driver name.
|
|
||||||
* Callbacks are passed an instance of the driver.
|
|
||||||
*
|
|
||||||
* @var array<string, callable>
|
|
||||||
*/
|
|
||||||
protected array $configureForRedirectCallbacks = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current config for the given driver allows auto-registration.
|
|
||||||
*/
|
|
||||||
public function isAutoRegisterEnabled(string $driver): bool
|
|
||||||
{
|
|
||||||
return $this->getDriverConfigProperty($driver, 'auto_register') === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current config for the given driver allow email address auto-confirmation.
|
|
||||||
*/
|
|
||||||
public function isAutoConfirmEmailEnabled(string $driver): bool
|
|
||||||
{
|
|
||||||
return $this->getDriverConfigProperty($driver, 'auto_confirm') === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the names of the active social drivers, keyed by driver id.
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function getActive(): array
|
|
||||||
{
|
|
||||||
$activeDrivers = [];
|
|
||||||
|
|
||||||
foreach ($this->validDrivers as $driverKey) {
|
|
||||||
if ($this->checkDriverConfigured($driverKey)) {
|
|
||||||
$activeDrivers[$driverKey] = $this->getName($driverKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $activeDrivers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the configure-for-redirect callback for the given driver.
|
|
||||||
* This is a callable that allows modification of the driver at redirect time.
|
|
||||||
* Commonly used to perform custom dynamic configuration where required.
|
|
||||||
* The callback is passed a \Laravel\Socialite\Contracts\Provider instance.
|
|
||||||
*/
|
|
||||||
public function getConfigureForRedirectCallback(string $driver): callable
|
|
||||||
{
|
|
||||||
return $this->configureForRedirectCallbacks[$driver] ?? (fn() => true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a custom socialite driver to be used.
|
|
||||||
* Driver name should be lower_snake_case.
|
|
||||||
* Config array should mirror the structure of a service
|
|
||||||
* within the `Config/services.php` file.
|
|
||||||
* Handler should be a Class@method handler to the SocialiteWasCalled event.
|
|
||||||
*/
|
|
||||||
public function addSocialDriver(
|
|
||||||
string $driverName,
|
|
||||||
array $config,
|
|
||||||
string $socialiteHandler,
|
|
||||||
?callable $configureForRedirect = null
|
|
||||||
) {
|
|
||||||
$this->validDrivers[] = $driverName;
|
|
||||||
config()->set('services.' . $driverName, $config);
|
|
||||||
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
|
|
||||||
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
|
|
||||||
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
|
|
||||||
if (!is_null($configureForRedirect)) {
|
|
||||||
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the presentational name for a driver.
|
|
||||||
*/
|
|
||||||
protected function getName(string $driver): string
|
|
||||||
{
|
|
||||||
return $this->getDriverConfigProperty($driver, 'name') ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getDriverConfigProperty(string $driver, string $property): mixed
|
|
||||||
{
|
|
||||||
return config("services.{$driver}.{$property}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the social driver is correct and supported.
|
|
||||||
*
|
|
||||||
* @throws SocialDriverNotConfigured
|
|
||||||
*/
|
|
||||||
public function ensureDriverActive(string $driverName): void
|
|
||||||
{
|
|
||||||
if (!in_array($driverName, $this->validDrivers)) {
|
|
||||||
abort(404, trans('errors.social_driver_not_found'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$this->checkDriverConfigured($driverName)) {
|
|
||||||
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($driverName)]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check a social driver has been configured correctly.
|
|
||||||
*/
|
|
||||||
protected function checkDriverConfigured(string $driver): bool
|
|
||||||
{
|
|
||||||
$lowerName = strtolower($driver);
|
|
||||||
$configPrefix = 'services.' . $lowerName . '.';
|
|
||||||
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
|
|
||||||
|
|
||||||
return !in_array(false, $config) && !in_array(null, $config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
class UserInviteException extends Exception
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Access;
|
|
||||||
|
|
||||||
use BookStack\Access\Notifications\UserInviteNotification;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
|
|
||||||
class UserInviteService extends UserTokenService
|
|
||||||
{
|
|
||||||
protected string $tokenTable = 'user_invites';
|
|
||||||
protected int $expiryTime = 336; // Two weeks
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an invitation to a user to sign into BookStack
|
|
||||||
* Removes existing invitation tokens.
|
|
||||||
* @throws UserInviteException
|
|
||||||
*/
|
|
||||||
public function sendInvitation(User $user)
|
|
||||||
{
|
|
||||||
$this->deleteByUser($user);
|
|
||||||
$token = $this->createTokenForUser($user);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$user->notify(new UserInviteNotification($token));
|
|
||||||
} catch (\Exception $exception) {
|
|
||||||
throw new UserInviteException($exception->getMessage(), $exception->getCode(), $exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
68
app/Actions/Activity.php
Normal file
68
app/Actions/Activity.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Actions;
|
||||||
|
|
||||||
|
use BookStack\Auth\User;
|
||||||
|
use BookStack\Entities\Models\Entity;
|
||||||
|
use BookStack\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property string $type
|
||||||
|
* @property User $user
|
||||||
|
* @property Entity $entity
|
||||||
|
* @property string $detail
|
||||||
|
* @property string $entity_type
|
||||||
|
* @property int $entity_id
|
||||||
|
* @property int $user_id
|
||||||
|
*/
|
||||||
|
class Activity extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the entity for this activity.
|
||||||
|
*/
|
||||||
|
public function entity(): MorphTo
|
||||||
|
{
|
||||||
|
if ($this->entity_type === '') {
|
||||||
|
$this->entity_type = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->morphTo('entity');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user this activity relates to.
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns text from the language files, Looks up by using the activity key.
|
||||||
|
*/
|
||||||
|
public function getText(): string
|
||||||
|
{
|
||||||
|
return trans('activities.' . $this->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this activity is intended to be for an entity.
|
||||||
|
*/
|
||||||
|
public function isForEntity(): bool
|
||||||
|
{
|
||||||
|
return Str::startsWith($this->type, [
|
||||||
|
'page_', 'chapter_', 'book_', 'bookshelf_',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if another Activity matches the general information of another.
|
||||||
|
*/
|
||||||
|
public function isSimilarTo(Activity $activityB): bool
|
||||||
|
{
|
||||||
|
return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
|
||||||
|
}
|
||||||
|
}
|
||||||
200
app/Actions/ActivityService.php
Normal file
200
app/Actions/ActivityService.php
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Actions;
|
||||||
|
|
||||||
|
use BookStack\Auth\Permissions\PermissionService;
|
||||||
|
use BookStack\Auth\User;
|
||||||
|
use BookStack\Entities\Models\Chapter;
|
||||||
|
use BookStack\Entities\Models\Entity;
|
||||||
|
use BookStack\Entities\Models\Page;
|
||||||
|
use BookStack\Interfaces\Loggable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ActivityService
|
||||||
|
{
|
||||||
|
protected $activity;
|
||||||
|
protected $permissionService;
|
||||||
|
|
||||||
|
public function __construct(Activity $activity, PermissionService $permissionService)
|
||||||
|
{
|
||||||
|
$this->activity = $activity;
|
||||||
|
$this->permissionService = $permissionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add activity data to database for an entity.
|
||||||
|
*/
|
||||||
|
public function addForEntity(Entity $entity, string $type)
|
||||||
|
{
|
||||||
|
$activity = $this->newActivityForUser($type);
|
||||||
|
$entity->activity()->save($activity);
|
||||||
|
$this->setNotification($type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a generic activity event to the database.
|
||||||
|
*
|
||||||
|
* @param string|Loggable $detail
|
||||||
|
*/
|
||||||
|
public function add(string $type, $detail = '')
|
||||||
|
{
|
||||||
|
if ($detail instanceof Loggable) {
|
||||||
|
$detail = $detail->logDescriptor();
|
||||||
|
}
|
||||||
|
|
||||||
|
$activity = $this->newActivityForUser($type);
|
||||||
|
$activity->detail = $detail;
|
||||||
|
$activity->save();
|
||||||
|
$this->setNotification($type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a new activity instance for the current user.
|
||||||
|
*/
|
||||||
|
protected function newActivityForUser(string $type): Activity
|
||||||
|
{
|
||||||
|
$ip = request()->ip() ?? '';
|
||||||
|
|
||||||
|
return $this->activity->newInstance()->forceFill([
|
||||||
|
'type' => strtolower($type),
|
||||||
|
'user_id' => user()->id,
|
||||||
|
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the entity attachment from each of its activities
|
||||||
|
* and instead uses the 'extra' field with the entities name.
|
||||||
|
* Used when an entity is deleted.
|
||||||
|
*/
|
||||||
|
public function removeEntity(Entity $entity)
|
||||||
|
{
|
||||||
|
$entity->activity()->update([
|
||||||
|
'detail' => $entity->name,
|
||||||
|
'entity_id' => null,
|
||||||
|
'entity_type' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the latest activity.
|
||||||
|
*/
|
||||||
|
public function latest(int $count = 20, int $page = 0): array
|
||||||
|
{
|
||||||
|
$activityList = $this->permissionService
|
||||||
|
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->with(['user', 'entity'])
|
||||||
|
->skip($count * $page)
|
||||||
|
->take($count)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->filterSimilar($activityList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the latest activity for an entity, Filtering out similar
|
||||||
|
* items to prevent a message activity list.
|
||||||
|
*/
|
||||||
|
public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
|
||||||
|
{
|
||||||
|
/** @var [string => int[]] $queryIds */
|
||||||
|
$queryIds = [$entity->getMorphClass() => [$entity->id]];
|
||||||
|
|
||||||
|
if ($entity->isA('book')) {
|
||||||
|
$queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->visible()->pluck('id');
|
||||||
|
}
|
||||||
|
if ($entity->isA('book') || $entity->isA('chapter')) {
|
||||||
|
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->visible()->pluck('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $this->activity->newQuery();
|
||||||
|
$query->where(function (Builder $query) use ($queryIds) {
|
||||||
|
foreach ($queryIds as $morphClass => $idArr) {
|
||||||
|
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
|
||||||
|
$innerQuery->where('entity_type', '=', $morphClass)
|
||||||
|
->whereIn('entity_id', $idArr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$activity = $query->orderBy('created_at', 'desc')
|
||||||
|
->with(['entity' => function (Relation $query) {
|
||||||
|
$query->withTrashed();
|
||||||
|
}, 'user.avatar'])
|
||||||
|
->skip($count * ($page - 1))
|
||||||
|
->take($count)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->filterSimilar($activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get latest activity for a user, Filtering out similar items.
|
||||||
|
*/
|
||||||
|
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||||
|
{
|
||||||
|
$activityList = $this->permissionService
|
||||||
|
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->where('user_id', '=', $user->id)
|
||||||
|
->skip($count * $page)
|
||||||
|
->take($count)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->filterSimilar($activityList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters out similar activity.
|
||||||
|
*
|
||||||
|
* @param Activity[] $activities
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function filterSimilar(iterable $activities): array
|
||||||
|
{
|
||||||
|
$newActivity = [];
|
||||||
|
$previousItem = null;
|
||||||
|
|
||||||
|
foreach ($activities as $activityItem) {
|
||||||
|
if (!$previousItem || !$activityItem->isSimilarTo($previousItem)) {
|
||||||
|
$newActivity[] = $activityItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousItem = $activityItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $newActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flashes a notification message to the session if an appropriate message is available.
|
||||||
|
*/
|
||||||
|
protected function setNotification(string $type)
|
||||||
|
{
|
||||||
|
$notificationTextKey = 'activities.' . $type . '_notification';
|
||||||
|
if (trans()->has($notificationTextKey)) {
|
||||||
|
$message = trans($notificationTextKey);
|
||||||
|
session()->flash('success', $message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log out a failed login attempt, Providing the given username
|
||||||
|
* as part of the message if the '%u' string is used.
|
||||||
|
*/
|
||||||
|
public function logFailedLogin(string $username)
|
||||||
|
{
|
||||||
|
$message = config('logging.failed_login.message');
|
||||||
|
if (!$message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = str_replace('%u', $username, $message);
|
||||||
|
$channel = config('logging.failed_login.channel');
|
||||||
|
Log::channel($channel)->warning($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace BookStack\Activity;
|
namespace BookStack\Actions;
|
||||||
|
|
||||||
class ActivityType
|
class ActivityType
|
||||||
{
|
{
|
||||||
@@ -16,26 +16,17 @@ class ActivityType
|
|||||||
const CHAPTER_MOVE = 'chapter_move';
|
const CHAPTER_MOVE = 'chapter_move';
|
||||||
|
|
||||||
const BOOK_CREATE = 'book_create';
|
const BOOK_CREATE = 'book_create';
|
||||||
const BOOK_CREATE_FROM_CHAPTER = 'book_create_from_chapter';
|
|
||||||
const BOOK_UPDATE = 'book_update';
|
const BOOK_UPDATE = 'book_update';
|
||||||
const BOOK_DELETE = 'book_delete';
|
const BOOK_DELETE = 'book_delete';
|
||||||
const BOOK_SORT = 'book_sort';
|
const BOOK_SORT = 'book_sort';
|
||||||
|
|
||||||
const BOOKSHELF_CREATE = 'bookshelf_create';
|
const BOOKSHELF_CREATE = 'bookshelf_create';
|
||||||
const BOOKSHELF_CREATE_FROM_BOOK = 'bookshelf_create_from_book';
|
|
||||||
const BOOKSHELF_UPDATE = 'bookshelf_update';
|
const BOOKSHELF_UPDATE = 'bookshelf_update';
|
||||||
const BOOKSHELF_DELETE = 'bookshelf_delete';
|
const BOOKSHELF_DELETE = 'bookshelf_delete';
|
||||||
|
|
||||||
const COMMENTED_ON = 'commented_on';
|
const COMMENTED_ON = 'commented_on';
|
||||||
const COMMENT_CREATE = 'comment_create';
|
|
||||||
const COMMENT_UPDATE = 'comment_update';
|
|
||||||
const COMMENT_DELETE = 'comment_delete';
|
|
||||||
|
|
||||||
const PERMISSIONS_UPDATE = 'permissions_update';
|
const PERMISSIONS_UPDATE = 'permissions_update';
|
||||||
|
|
||||||
const REVISION_RESTORE = 'revision_restore';
|
|
||||||
const REVISION_DELETE = 'revision_delete';
|
|
||||||
|
|
||||||
const SETTINGS_UPDATE = 'settings_update';
|
const SETTINGS_UPDATE = 'settings_update';
|
||||||
const MAINTENANCE_ACTION_RUN = 'maintenance_action_run';
|
const MAINTENANCE_ACTION_RUN = 'maintenance_action_run';
|
||||||
|
|
||||||
@@ -62,24 +53,4 @@ class ActivityType
|
|||||||
|
|
||||||
const MFA_SETUP_METHOD = 'mfa_setup_method';
|
const MFA_SETUP_METHOD = 'mfa_setup_method';
|
||||||
const MFA_REMOVE_METHOD = 'mfa_remove_method';
|
const MFA_REMOVE_METHOD = 'mfa_remove_method';
|
||||||
|
|
||||||
const WEBHOOK_CREATE = 'webhook_create';
|
|
||||||
const WEBHOOK_UPDATE = 'webhook_update';
|
|
||||||
const WEBHOOK_DELETE = 'webhook_delete';
|
|
||||||
|
|
||||||
const IMPORT_CREATE = 'import_create';
|
|
||||||
const IMPORT_RUN = 'import_run';
|
|
||||||
const IMPORT_DELETE = 'import_delete';
|
|
||||||
|
|
||||||
const SORT_RULE_CREATE = 'sort_rule_create';
|
|
||||||
const SORT_RULE_UPDATE = 'sort_rule_update';
|
|
||||||
const SORT_RULE_DELETE = 'sort_rule_delete';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all the possible values.
|
|
||||||
*/
|
|
||||||
public static function all(): array
|
|
||||||
{
|
|
||||||
return (new \ReflectionClass(static::class))->getConstants();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
58
app/Actions/Comment.php
Normal file
58
app/Actions/Comment.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Actions;
|
||||||
|
|
||||||
|
use BookStack\Model;
|
||||||
|
use BookStack\Traits\HasCreatorAndUpdater;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property string $text
|
||||||
|
* @property string $html
|
||||||
|
* @property int|null $parent_id
|
||||||
|
* @property int $local_id
|
||||||
|
*/
|
||||||
|
class Comment extends Model
|
||||||
|
{
|
||||||
|
use HasCreatorAndUpdater;
|
||||||
|
|
||||||
|
protected $fillable = ['text', 'parent_id'];
|
||||||
|
protected $appends = ['created', 'updated'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the entity that this comment belongs to.
|
||||||
|
*/
|
||||||
|
public function entity(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo('entity');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a comment has been updated since creation.
|
||||||
|
*/
|
||||||
|
public function isUpdated(): bool
|
||||||
|
{
|
||||||
|
return $this->updated_at->timestamp > $this->created_at->timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get created date as a relative diff.
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function getCreatedAttribute()
|
||||||
|
{
|
||||||
|
return $this->created_at->diffForHumans();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get updated date as a relative diff.
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function getUpdatedAttribute()
|
||||||
|
{
|
||||||
|
return $this->updated_at->diffForHumans();
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/Actions/CommentRepo.php
Normal file
97
app/Actions/CommentRepo.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Actions;
|
||||||
|
|
||||||
|
use BookStack\Entities\Models\Entity;
|
||||||
|
use BookStack\Facades\Activity as ActivityService;
|
||||||
|
use League\CommonMark\CommonMarkConverter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class CommentRepo.
|
||||||
|
*/
|
||||||
|
class CommentRepo
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var Comment
|
||||||
|
*/
|
||||||
|
protected $comment;
|
||||||
|
|
||||||
|
public function __construct(Comment $comment)
|
||||||
|
{
|
||||||
|
$this->comment = $comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a comment by ID.
|
||||||
|
*/
|
||||||
|
public function getById(int $id): Comment
|
||||||
|
{
|
||||||
|
return $this->comment->newQuery()->findOrFail($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new comment on an entity.
|
||||||
|
*/
|
||||||
|
public function create(Entity $entity, string $text, ?int $parent_id): Comment
|
||||||
|
{
|
||||||
|
$userId = user()->id;
|
||||||
|
$comment = $this->comment->newInstance();
|
||||||
|
|
||||||
|
$comment->text = $text;
|
||||||
|
$comment->html = $this->commentToHtml($text);
|
||||||
|
$comment->created_by = $userId;
|
||||||
|
$comment->updated_by = $userId;
|
||||||
|
$comment->local_id = $this->getNextLocalId($entity);
|
||||||
|
$comment->parent_id = $parent_id;
|
||||||
|
|
||||||
|
$entity->comments()->save($comment);
|
||||||
|
ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
|
||||||
|
|
||||||
|
return $comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing comment.
|
||||||
|
*/
|
||||||
|
public function update(Comment $comment, string $text): Comment
|
||||||
|
{
|
||||||
|
$comment->updated_by = user()->id;
|
||||||
|
$comment->text = $text;
|
||||||
|
$comment->html = $this->commentToHtml($text);
|
||||||
|
$comment->save();
|
||||||
|
|
||||||
|
return $comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a comment from the system.
|
||||||
|
*/
|
||||||
|
public function delete(Comment $comment)
|
||||||
|
{
|
||||||
|
$comment->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the given comment markdown text to HTML.
|
||||||
|
*/
|
||||||
|
public function commentToHtml(string $commentText): string
|
||||||
|
{
|
||||||
|
$converter = new CommonMarkConverter([
|
||||||
|
'html_input' => 'strip',
|
||||||
|
'max_nesting_level' => 10,
|
||||||
|
'allow_unsafe_links' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $converter->convertToHtml($commentText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next local ID relative to the linked entity.
|
||||||
|
*/
|
||||||
|
protected function getNextLocalId(Entity $entity): int
|
||||||
|
{
|
||||||
|
$comments = $entity->comments(false)->orderBy('local_id', 'desc')->first();
|
||||||
|
|
||||||
|
return ($comments->local_id ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Actions/Favourite.php
Normal file
19
app/Actions/Favourite.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Actions;
|
||||||
|
|
||||||
|
use BookStack\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
class Favourite extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['user_id'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the related model that can be favourited.
|
||||||
|
*/
|
||||||
|
public function favouritable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace BookStack\Activity\Models;
|
namespace BookStack\Actions;
|
||||||
|
|
||||||
use BookStack\App\Model;
|
use BookStack\Model;
|
||||||
use BookStack\Permissions\Models\JointPermission;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property string $name
|
|
||||||
* @property string $value
|
|
||||||
* @property int $entity_id
|
|
||||||
* @property string $entity_type
|
|
||||||
* @property int $order
|
|
||||||
*/
|
|
||||||
class Tag extends Model
|
class Tag extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = ['name', 'value', 'order'];
|
protected $fillable = ['name', 'value', 'order'];
|
||||||
protected $hidden = ['id', 'entity_id', 'entity_type', 'created_at', 'updated_at'];
|
protected $hidden = ['id', 'entity_id', 'entity_type', 'created_at', 'updated_at'];
|
||||||
|
|
||||||
@@ -31,12 +18,6 @@ class Tag extends Model
|
|||||||
return $this->morphTo('entity');
|
return $this->morphTo('entity');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function jointPermissions(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
|
|
||||||
->whereColumn('tags.entity_type', '=', 'joint_permissions.entity_type');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a full URL to start a tag name search for this tag name.
|
* Get a full URL to start a tag name search for this tag name.
|
||||||
*/
|
*/
|
||||||
98
app/Actions/TagRepo.php
Normal file
98
app/Actions/TagRepo.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Actions;
|
||||||
|
|
||||||
|
use BookStack\Auth\Permissions\PermissionService;
|
||||||
|
use BookStack\Entities\Models\Entity;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class TagRepo
|
||||||
|
{
|
||||||
|
protected $tag;
|
||||||
|
protected $permissionService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TagRepo constructor.
|
||||||
|
*/
|
||||||
|
public function __construct(Tag $tag, PermissionService $ps)
|
||||||
|
{
|
||||||
|
$this->tag = $tag;
|
||||||
|
$this->permissionService = $ps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
$query = $this->tag->newQuery()
|
||||||
|
->select('*', DB::raw('count(*) as count'))
|
||||||
|
->groupBy('name');
|
||||||
|
|
||||||
|
if ($searchTerm) {
|
||||||
|
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
|
||||||
|
} else {
|
||||||
|
$query = $query->orderBy('count', 'desc')->take(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||||
|
|
||||||
|
return $query->get(['name'])->pluck('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tag value suggestions from scanning existing tag values.
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
$query = $this->tag->newQuery()
|
||||||
|
->select('*', DB::raw('count(*) as count'))
|
||||||
|
->groupBy('value');
|
||||||
|
|
||||||
|
if ($searchTerm) {
|
||||||
|
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
|
||||||
|
} else {
|
||||||
|
$query = $query->orderBy('count', 'desc')->take(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tagName) {
|
||||||
|
$query = $query->where('name', '=', $tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||||
|
|
||||||
|
return $query->get(['value'])->pluck('value');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an array of tags to an entity.
|
||||||
|
*/
|
||||||
|
public function saveTagsToEntity(Entity $entity, array $tags = []): iterable
|
||||||
|
{
|
||||||
|
$entity->tags()->delete();
|
||||||
|
|
||||||
|
$newTags = collect($tags)->filter(function ($tag) {
|
||||||
|
return boolval(trim($tag['name']));
|
||||||
|
})->map(function ($tag) {
|
||||||
|
return $this->newInstanceFromInput($tag);
|
||||||
|
})->all();
|
||||||
|
|
||||||
|
return $entity->tags()->saveMany($newTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Tag instance from user input.
|
||||||
|
* Input must be an array with a 'name' and an optional 'value' key.
|
||||||
|
*/
|
||||||
|
protected function newInstanceFromInput(array $input): Tag
|
||||||
|
{
|
||||||
|
$name = trim($input['name']);
|
||||||
|
$value = isset($input['value']) ? trim($input['value']) : '';
|
||||||
|
|
||||||
|
return $this->tag->newInstance(['name' => $name, 'value' => $value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace BookStack\Activity\Models;
|
namespace BookStack\Actions;
|
||||||
|
|
||||||
use BookStack\App\Model;
|
use BookStack\Interfaces\Viewable;
|
||||||
use BookStack\Permissions\Models\JointPermission;
|
use BookStack\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,19 +28,13 @@ class View extends Model
|
|||||||
return $this->morphTo();
|
return $this->morphTo();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function jointPermissions(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(JointPermission::class, 'entity_id', 'viewable_id')
|
|
||||||
->whereColumn('views.viewable_type', '=', 'joint_permissions.entity_type');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Increment the current user's view count for the given viewable model.
|
* Increment the current user's view count for the given viewable model.
|
||||||
*/
|
*/
|
||||||
public static function incrementFor(Viewable $viewable): int
|
public static function incrementFor(Viewable $viewable): int
|
||||||
{
|
{
|
||||||
$user = user();
|
$user = user();
|
||||||
if ($user->isGuest()) {
|
if (is_null($user) || $user->isDefault()) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,4 +47,12 @@ class View extends Model
|
|||||||
|
|
||||||
return $view->views;
|
return $view->views;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all views from the system.
|
||||||
|
*/
|
||||||
|
public static function clearAll()
|
||||||
|
{
|
||||||
|
static::query()->truncate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Activity;
|
|
||||||
use BookStack\Entities\Models\Book;
|
|
||||||
use BookStack\Entities\Models\Chapter;
|
|
||||||
use BookStack\Entities\Models\Entity;
|
|
||||||
use BookStack\Entities\Models\Page;
|
|
||||||
use BookStack\Entities\Tools\MixedEntityListLoader;
|
|
||||||
use BookStack\Permissions\PermissionApplicator;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
|
||||||
|
|
||||||
class ActivityQueries
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected PermissionApplicator $permissions,
|
|
||||||
protected MixedEntityListLoader $listLoader,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the latest activity.
|
|
||||||
*/
|
|
||||||
public function latest(int $count = 20, int $page = 0): array
|
|
||||||
{
|
|
||||||
$activityList = $this->permissions
|
|
||||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
|
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->with(['user'])
|
|
||||||
->skip($count * $page)
|
|
||||||
->take($count)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$this->listLoader->loadIntoRelations($activityList->all(), 'loggable', false);
|
|
||||||
|
|
||||||
return $this->filterSimilar($activityList);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the latest activity for an entity, Filtering out similar
|
|
||||||
* items to prevent a message activity list.
|
|
||||||
*/
|
|
||||||
public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
|
|
||||||
{
|
|
||||||
/** @var array<string, int[]> $queryIds */
|
|
||||||
$queryIds = [$entity->getMorphClass() => [$entity->id]];
|
|
||||||
|
|
||||||
if ($entity instanceof Book) {
|
|
||||||
$queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->scopes('visible')->pluck('id');
|
|
||||||
}
|
|
||||||
if ($entity instanceof Book || $entity instanceof Chapter) {
|
|
||||||
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
$query = Activity::query();
|
|
||||||
$query->where(function (Builder $query) use ($queryIds) {
|
|
||||||
foreach ($queryIds as $morphClass => $idArr) {
|
|
||||||
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
|
|
||||||
$innerQuery->where('loggable_type', '=', $morphClass)
|
|
||||||
->whereIn('loggable_id', $idArr);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$activity = $query->orderBy('created_at', 'desc')
|
|
||||||
->with(['loggable' => function (Relation $query) {
|
|
||||||
/** @var MorphTo<Entity, Activity> $query */
|
|
||||||
$query->withTrashed();
|
|
||||||
}, 'user.avatar'])
|
|
||||||
->skip($count * ($page - 1))
|
|
||||||
->take($count)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $this->filterSimilar($activity);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the latest activity for a user, Filtering out similar items.
|
|
||||||
*/
|
|
||||||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
|
||||||
{
|
|
||||||
$activityList = $this->permissions
|
|
||||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
|
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->where('user_id', '=', $user->id)
|
|
||||||
->skip($count * $page)
|
|
||||||
->take($count)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $this->filterSimilar($activityList);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters out similar activity.
|
|
||||||
*
|
|
||||||
* @param Activity[] $activities
|
|
||||||
*/
|
|
||||||
protected function filterSimilar(iterable $activities): array
|
|
||||||
{
|
|
||||||
$newActivity = [];
|
|
||||||
$previousItem = null;
|
|
||||||
|
|
||||||
foreach ($activities as $activityItem) {
|
|
||||||
if (!$previousItem || !$activityItem->isSimilarTo($previousItem)) {
|
|
||||||
$newActivity[] = $activityItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
$previousItem = $activityItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $newActivity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Comment;
|
|
||||||
use BookStack\Entities\Models\Entity;
|
|
||||||
use BookStack\Exceptions\NotifyException;
|
|
||||||
use BookStack\Exceptions\PrettyException;
|
|
||||||
use BookStack\Facades\Activity as ActivityService;
|
|
||||||
use BookStack\Util\HtmlDescriptionFilter;
|
|
||||||
|
|
||||||
class CommentRepo
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get a comment by ID.
|
|
||||||
*/
|
|
||||||
public function getById(int $id): Comment
|
|
||||||
{
|
|
||||||
return Comment::query()->findOrFail($id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new comment on an entity.
|
|
||||||
*/
|
|
||||||
public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
|
|
||||||
{
|
|
||||||
$userId = user()->id;
|
|
||||||
$comment = new Comment();
|
|
||||||
|
|
||||||
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
|
||||||
$comment->created_by = $userId;
|
|
||||||
$comment->updated_by = $userId;
|
|
||||||
$comment->local_id = $this->getNextLocalId($entity);
|
|
||||||
$comment->parent_id = $parentId;
|
|
||||||
$comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $contentRef) === 1 ? $contentRef : '';
|
|
||||||
|
|
||||||
$entity->comments()->save($comment);
|
|
||||||
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
|
|
||||||
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
|
||||||
|
|
||||||
return $comment;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing comment.
|
|
||||||
*/
|
|
||||||
public function update(Comment $comment, string $html): Comment
|
|
||||||
{
|
|
||||||
$comment->updated_by = user()->id;
|
|
||||||
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
|
||||||
$comment->save();
|
|
||||||
|
|
||||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
|
||||||
|
|
||||||
return $comment;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Archive an existing comment.
|
|
||||||
*/
|
|
||||||
public function archive(Comment $comment): Comment
|
|
||||||
{
|
|
||||||
if ($comment->parent_id) {
|
|
||||||
throw new NotifyException('Only top-level comments can be archived.', '/', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$comment->archived = true;
|
|
||||||
$comment->save();
|
|
||||||
|
|
||||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
|
||||||
|
|
||||||
return $comment;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Un-archive an existing comment.
|
|
||||||
*/
|
|
||||||
public function unarchive(Comment $comment): Comment
|
|
||||||
{
|
|
||||||
if ($comment->parent_id) {
|
|
||||||
throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$comment->archived = false;
|
|
||||||
$comment->save();
|
|
||||||
|
|
||||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
|
||||||
|
|
||||||
return $comment;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a comment from the system.
|
|
||||||
*/
|
|
||||||
public function delete(Comment $comment): void
|
|
||||||
{
|
|
||||||
$comment->delete();
|
|
||||||
|
|
||||||
ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the next local ID relative to the linked entity.
|
|
||||||
*/
|
|
||||||
protected function getNextLocalId(Entity $entity): int
|
|
||||||
{
|
|
||||||
$currentMaxId = $entity->comments()->max('local_id');
|
|
||||||
|
|
||||||
return $currentMaxId + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Controllers;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Activity;
|
|
||||||
use BookStack\Http\ApiController;
|
|
||||||
use BookStack\Permissions\Permission;
|
|
||||||
|
|
||||||
class AuditLogApiController extends ApiController
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get a listing of audit log events in the system.
|
|
||||||
* The loggable relation fields currently only relates to core
|
|
||||||
* content types (page, book, bookshelf, chapter) but this may be
|
|
||||||
* used more in the future across other types.
|
|
||||||
* Requires permission to manage both users and system settings.
|
|
||||||
*/
|
|
||||||
public function list()
|
|
||||||
{
|
|
||||||
$this->checkPermission(Permission::SettingsManage);
|
|
||||||
$this->checkPermission(Permission::UsersManage);
|
|
||||||
|
|
||||||
$query = Activity::query()->with(['user']);
|
|
||||||
|
|
||||||
return $this->apiListingResponse($query, [
|
|
||||||
'id', 'type', 'detail', 'user_id', 'loggable_id', 'loggable_type', 'ip', 'created_at',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Controllers;
|
|
||||||
|
|
||||||
use BookStack\Activity\ActivityType;
|
|
||||||
use BookStack\Activity\Models\Activity;
|
|
||||||
use BookStack\Http\Controller;
|
|
||||||
use BookStack\Permissions\Permission;
|
|
||||||
use BookStack\Sorting\SortUrl;
|
|
||||||
use BookStack\Util\SimpleListOptions;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class AuditLogController extends Controller
|
|
||||||
{
|
|
||||||
public function index(Request $request)
|
|
||||||
{
|
|
||||||
$this->checkPermission(Permission::SettingsManage);
|
|
||||||
$this->checkPermission(Permission::UsersManage);
|
|
||||||
|
|
||||||
$sort = $request->get('sort', 'activity_date');
|
|
||||||
$order = $request->get('order', 'desc');
|
|
||||||
$listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
|
|
||||||
'created_at' => trans('settings.audit_table_date'),
|
|
||||||
'type' => trans('settings.audit_table_event'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$filters = [
|
|
||||||
'event' => $request->get('event', ''),
|
|
||||||
'date_from' => $request->get('date_from', ''),
|
|
||||||
'date_to' => $request->get('date_to', ''),
|
|
||||||
'user' => $request->get('user', ''),
|
|
||||||
'ip' => $request->get('ip', ''),
|
|
||||||
];
|
|
||||||
|
|
||||||
$query = Activity::query()
|
|
||||||
->with([
|
|
||||||
'loggable' => fn ($query) => $query->withTrashed(),
|
|
||||||
'user',
|
|
||||||
])
|
|
||||||
->orderBy($listOptions->getSort(), $listOptions->getOrder());
|
|
||||||
|
|
||||||
if ($filters['event']) {
|
|
||||||
$query->where('type', '=', $filters['event']);
|
|
||||||
}
|
|
||||||
if ($filters['user']) {
|
|
||||||
$query->where('user_id', '=', $filters['user']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($filters['date_from']) {
|
|
||||||
$query->where('created_at', '>=', $filters['date_from']);
|
|
||||||
}
|
|
||||||
if ($filters['date_to']) {
|
|
||||||
$query->where('created_at', '<=', $filters['date_to']);
|
|
||||||
}
|
|
||||||
if ($filters['ip']) {
|
|
||||||
$query->where('ip', 'like', $filters['ip'] . '%');
|
|
||||||
}
|
|
||||||
|
|
||||||
$activities = $query->paginate(100);
|
|
||||||
$activities->appends($request->all());
|
|
||||||
|
|
||||||
$types = ActivityType::all();
|
|
||||||
$this->setPageTitle(trans('settings.audit'));
|
|
||||||
|
|
||||||
return view('settings.audit', [
|
|
||||||
'activities' => $activities,
|
|
||||||
'filters' => $filters,
|
|
||||||
'listOptions' => $listOptions,
|
|
||||||
'activityTypes' => $types,
|
|
||||||
'filterSortUrl' => new SortUrl('settings/audit', array_filter($request->except('page')))
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Controllers;
|
|
||||||
|
|
||||||
use BookStack\Activity\CommentRepo;
|
|
||||||
use BookStack\Activity\Tools\CommentTree;
|
|
||||||
use BookStack\Activity\Tools\CommentTreeNode;
|
|
||||||
use BookStack\Entities\Queries\PageQueries;
|
|
||||||
use BookStack\Http\Controller;
|
|
||||||
use BookStack\Permissions\Permission;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class CommentController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected CommentRepo $commentRepo,
|
|
||||||
protected PageQueries $pageQueries,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a new comment for a Page.
|
|
||||||
*
|
|
||||||
* @throws ValidationException
|
|
||||||
*/
|
|
||||||
public function savePageComment(Request $request, int $pageId)
|
|
||||||
{
|
|
||||||
$input = $this->validate($request, [
|
|
||||||
'html' => ['required', 'string'],
|
|
||||||
'parent_id' => ['nullable', 'integer'],
|
|
||||||
'content_ref' => ['string'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$page = $this->pageQueries->findVisibleById($pageId);
|
|
||||||
if ($page === null) {
|
|
||||||
return response('Not found', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent adding comments to draft pages
|
|
||||||
if ($page->draft) {
|
|
||||||
return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new comment.
|
|
||||||
$this->checkPermission(Permission::CommentCreateAll);
|
|
||||||
$contentRef = $input['content_ref'] ?? '';
|
|
||||||
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef);
|
|
||||||
|
|
||||||
return view('comments.comment-branch', [
|
|
||||||
'readOnly' => false,
|
|
||||||
'branch' => new CommentTreeNode($comment, 0, []),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing comment.
|
|
||||||
*
|
|
||||||
* @throws ValidationException
|
|
||||||
*/
|
|
||||||
public function update(Request $request, int $commentId)
|
|
||||||
{
|
|
||||||
$input = $this->validate($request, [
|
|
||||||
'html' => ['required', 'string'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$comment = $this->commentRepo->getById($commentId);
|
|
||||||
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
|
|
||||||
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
|
|
||||||
|
|
||||||
$comment = $this->commentRepo->update($comment, $input['html']);
|
|
||||||
|
|
||||||
return view('comments.comment', [
|
|
||||||
'comment' => $comment,
|
|
||||||
'readOnly' => false,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark a comment as archived.
|
|
||||||
*/
|
|
||||||
public function archive(int $id)
|
|
||||||
{
|
|
||||||
$comment = $this->commentRepo->getById($id);
|
|
||||||
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
|
|
||||||
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
|
|
||||||
$this->showPermissionError();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->commentRepo->archive($comment);
|
|
||||||
|
|
||||||
$tree = new CommentTree($comment->entity);
|
|
||||||
return view('comments.comment-branch', [
|
|
||||||
'readOnly' => false,
|
|
||||||
'branch' => $tree->getCommentNodeForId($id),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unmark a comment as archived.
|
|
||||||
*/
|
|
||||||
public function unarchive(int $id)
|
|
||||||
{
|
|
||||||
$comment = $this->commentRepo->getById($id);
|
|
||||||
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
|
|
||||||
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
|
|
||||||
$this->showPermissionError();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->commentRepo->unarchive($comment);
|
|
||||||
|
|
||||||
$tree = new CommentTree($comment->entity);
|
|
||||||
return view('comments.comment-branch', [
|
|
||||||
'readOnly' => false,
|
|
||||||
'branch' => $tree->getCommentNodeForId($id),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a comment from the system.
|
|
||||||
*/
|
|
||||||
public function destroy(int $id)
|
|
||||||
{
|
|
||||||
$comment = $this->commentRepo->getById($id);
|
|
||||||
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
|
|
||||||
|
|
||||||
$this->commentRepo->delete($comment);
|
|
||||||
|
|
||||||
return response()->json(['message' => trans('entities.comment_deleted')]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Controllers;
|
|
||||||
|
|
||||||
use BookStack\Entities\Queries\QueryTopFavourites;
|
|
||||||
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
|
||||||
use BookStack\Http\Controller;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class FavouriteController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected MixedEntityRequestHelper $entityHelper,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a listing of all favourite items for the current user.
|
|
||||||
*/
|
|
||||||
public function index(Request $request, QueryTopFavourites $topFavourites)
|
|
||||||
{
|
|
||||||
$viewCount = 20;
|
|
||||||
$page = intval($request->get('page', 1));
|
|
||||||
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
|
|
||||||
|
|
||||||
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
|
|
||||||
|
|
||||||
$this->setPageTitle(trans('entities.my_favourites'));
|
|
||||||
|
|
||||||
return view('common.detailed-listing-with-more', [
|
|
||||||
'title' => trans('entities.my_favourites'),
|
|
||||||
'entities' => $favourites->slice(0, $viewCount),
|
|
||||||
'hasMoreLink' => $hasMoreLink,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new item as a favourite.
|
|
||||||
*/
|
|
||||||
public function add(Request $request)
|
|
||||||
{
|
|
||||||
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
|
|
||||||
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
|
|
||||||
$entity->favourites()->firstOrCreate([
|
|
||||||
'user_id' => user()->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->showSuccessNotification(trans('activities.favourite_add_notification', [
|
|
||||||
'name' => $entity->name,
|
|
||||||
]));
|
|
||||||
|
|
||||||
return redirect($entity->getUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove an item as a favourite.
|
|
||||||
*/
|
|
||||||
public function remove(Request $request)
|
|
||||||
{
|
|
||||||
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
|
|
||||||
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
|
|
||||||
$entity->favourites()->where([
|
|
||||||
'user_id' => user()->id,
|
|
||||||
])->delete();
|
|
||||||
|
|
||||||
$this->showSuccessNotification(trans('activities.favourite_remove_notification', [
|
|
||||||
'name' => $entity->name,
|
|
||||||
]));
|
|
||||||
|
|
||||||
return redirect($entity->getUrl());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Controllers;
|
|
||||||
|
|
||||||
use BookStack\Activity\TagRepo;
|
|
||||||
use BookStack\Http\Controller;
|
|
||||||
use BookStack\Util\SimpleListOptions;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class TagController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected TagRepo $tagRepo
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a listing of existing tags in the system.
|
|
||||||
*/
|
|
||||||
public function index(Request $request)
|
|
||||||
{
|
|
||||||
$listOptions = SimpleListOptions::fromRequest($request, 'tags')->withSortOptions([
|
|
||||||
'name' => trans('common.sort_name'),
|
|
||||||
'usages' => trans('entities.tags_usages'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$nameFilter = $request->get('name', '');
|
|
||||||
$tags = $this->tagRepo
|
|
||||||
->queryWithTotals($listOptions, $nameFilter)
|
|
||||||
->paginate(50)
|
|
||||||
->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [
|
|
||||||
'name' => $nameFilter,
|
|
||||||
])));
|
|
||||||
|
|
||||||
$this->setPageTitle(trans('entities.tags'));
|
|
||||||
|
|
||||||
return view('tags.index', [
|
|
||||||
'tags' => $tags,
|
|
||||||
'nameFilter' => $nameFilter,
|
|
||||||
'listOptions' => $listOptions,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tag name suggestions from a given search term.
|
|
||||||
*/
|
|
||||||
public function getNameSuggestions(Request $request)
|
|
||||||
{
|
|
||||||
$searchTerm = $request->get('search', '');
|
|
||||||
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
|
||||||
|
|
||||||
return response()->json($suggestions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tag value suggestions from a given search term.
|
|
||||||
*/
|
|
||||||
public function getValueSuggestions(Request $request)
|
|
||||||
{
|
|
||||||
$searchTerm = $request->get('search', '');
|
|
||||||
$tagName = $request->get('name', '');
|
|
||||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
|
||||||
|
|
||||||
return response()->json($suggestions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Controllers;
|
|
||||||
|
|
||||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
|
||||||
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
|
||||||
use BookStack\Http\Controller;
|
|
||||||
use BookStack\Permissions\Permission;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class WatchController extends Controller
|
|
||||||
{
|
|
||||||
public function update(Request $request, MixedEntityRequestHelper $entityHelper)
|
|
||||||
{
|
|
||||||
$this->checkPermission(Permission::ReceiveNotifications);
|
|
||||||
$this->preventGuestAccess();
|
|
||||||
|
|
||||||
$requestData = $this->validate($request, array_merge([
|
|
||||||
'level' => ['required', 'string'],
|
|
||||||
], $entityHelper->validationRules()));
|
|
||||||
|
|
||||||
$watchable = $entityHelper->getVisibleEntityFromRequestData($requestData);
|
|
||||||
$watchOptions = new UserEntityWatchOptions(user(), $watchable);
|
|
||||||
$watchOptions->updateLevelByName($requestData['level']);
|
|
||||||
|
|
||||||
$this->showSuccessNotification(trans('activities.watch_update_level_notification'));
|
|
||||||
|
|
||||||
return redirect($watchable->getUrl());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Controllers;
|
|
||||||
|
|
||||||
use BookStack\Activity\ActivityType;
|
|
||||||
use BookStack\Activity\Models\Webhook;
|
|
||||||
use BookStack\Activity\Queries\WebhooksAllPaginatedAndSorted;
|
|
||||||
use BookStack\Http\Controller;
|
|
||||||
use BookStack\Permissions\Permission;
|
|
||||||
use BookStack\Util\SimpleListOptions;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class WebhookController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->middleware([
|
|
||||||
Permission::SettingsManage->middleware()
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show all webhooks configured in the system.
|
|
||||||
*/
|
|
||||||
public function index(Request $request)
|
|
||||||
{
|
|
||||||
$listOptions = SimpleListOptions::fromRequest($request, 'webhooks')->withSortOptions([
|
|
||||||
'name' => trans('common.sort_name'),
|
|
||||||
'endpoint' => trans('settings.webhooks_endpoint'),
|
|
||||||
'created_at' => trans('common.sort_created_at'),
|
|
||||||
'updated_at' => trans('common.sort_updated_at'),
|
|
||||||
'active' => trans('common.status'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$webhooks = (new WebhooksAllPaginatedAndSorted())->run(20, $listOptions);
|
|
||||||
$webhooks->appends($listOptions->getPaginationAppends());
|
|
||||||
|
|
||||||
$this->setPageTitle(trans('settings.webhooks'));
|
|
||||||
|
|
||||||
return view('settings.webhooks.index', [
|
|
||||||
'webhooks' => $webhooks,
|
|
||||||
'listOptions' => $listOptions,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the view for creating a new webhook in the system.
|
|
||||||
*/
|
|
||||||
public function create()
|
|
||||||
{
|
|
||||||
$this->setPageTitle(trans('settings.webhooks_create'));
|
|
||||||
|
|
||||||
return view('settings.webhooks.create');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a new webhook in the system.
|
|
||||||
*/
|
|
||||||
public function store(Request $request)
|
|
||||||
{
|
|
||||||
$validated = $this->validate($request, [
|
|
||||||
'name' => ['required', 'max:150'],
|
|
||||||
'endpoint' => ['required', 'url', 'max:500'],
|
|
||||||
'events' => ['required', 'array'],
|
|
||||||
'active' => ['required'],
|
|
||||||
'timeout' => ['required', 'integer', 'min:1', 'max:600'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$webhook = new Webhook($validated);
|
|
||||||
$webhook->active = $validated['active'] === 'true';
|
|
||||||
$webhook->save();
|
|
||||||
$webhook->updateTrackedEvents(array_values($validated['events']));
|
|
||||||
|
|
||||||
$this->logActivity(ActivityType::WEBHOOK_CREATE, $webhook);
|
|
||||||
|
|
||||||
return redirect('/settings/webhooks');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the view to edit an existing webhook.
|
|
||||||
*/
|
|
||||||
public function edit(string $id)
|
|
||||||
{
|
|
||||||
/** @var Webhook $webhook */
|
|
||||||
$webhook = Webhook::query()
|
|
||||||
->with('trackedEvents')
|
|
||||||
->findOrFail($id);
|
|
||||||
|
|
||||||
$this->setPageTitle(trans('settings.webhooks_edit'));
|
|
||||||
|
|
||||||
return view('settings.webhooks.edit', ['webhook' => $webhook]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing webhook with the provided request data.
|
|
||||||
*/
|
|
||||||
public function update(Request $request, string $id)
|
|
||||||
{
|
|
||||||
$validated = $this->validate($request, [
|
|
||||||
'name' => ['required', 'max:150'],
|
|
||||||
'endpoint' => ['required', 'url', 'max:500'],
|
|
||||||
'events' => ['required', 'array'],
|
|
||||||
'active' => ['required'],
|
|
||||||
'timeout' => ['required', 'integer', 'min:1', 'max:600'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** @var Webhook $webhook */
|
|
||||||
$webhook = Webhook::query()->findOrFail($id);
|
|
||||||
|
|
||||||
$webhook->active = $validated['active'] === 'true';
|
|
||||||
$webhook->fill($validated)->save();
|
|
||||||
$webhook->updateTrackedEvents($validated['events']);
|
|
||||||
|
|
||||||
$this->logActivity(ActivityType::WEBHOOK_UPDATE, $webhook);
|
|
||||||
|
|
||||||
return redirect('/settings/webhooks');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the view to delete a webhook.
|
|
||||||
*/
|
|
||||||
public function delete(string $id)
|
|
||||||
{
|
|
||||||
/** @var Webhook $webhook */
|
|
||||||
$webhook = Webhook::query()->findOrFail($id);
|
|
||||||
|
|
||||||
$this->setPageTitle(trans('settings.webhooks_delete'));
|
|
||||||
|
|
||||||
return view('settings.webhooks.delete', ['webhook' => $webhook]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy a webhook from the system.
|
|
||||||
*/
|
|
||||||
public function destroy(string $id)
|
|
||||||
{
|
|
||||||
/** @var Webhook $webhook */
|
|
||||||
$webhook = Webhook::query()->findOrFail($id);
|
|
||||||
|
|
||||||
$webhook->trackedEvents()->delete();
|
|
||||||
$webhook->delete();
|
|
||||||
|
|
||||||
$this->logActivity(ActivityType::WEBHOOK_DELETE, $webhook);
|
|
||||||
|
|
||||||
return redirect('/settings/webhooks');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Loggable;
|
|
||||||
use BookStack\Activity\Models\Webhook;
|
|
||||||
use BookStack\Activity\Tools\WebhookFormatter;
|
|
||||||
use BookStack\Facades\Theme;
|
|
||||||
use BookStack\Http\HttpRequestService;
|
|
||||||
use BookStack\Theming\ThemeEvents;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use BookStack\Util\SsrUrlValidator;
|
|
||||||
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\Facades\Log;
|
|
||||||
|
|
||||||
class DispatchWebhookJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
protected Webhook $webhook;
|
|
||||||
protected User $initiator;
|
|
||||||
protected int $initiatedTime;
|
|
||||||
protected array $webhookData;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new job instance.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function __construct(Webhook $webhook, string $event, Loggable|string $detail)
|
|
||||||
{
|
|
||||||
$this->webhook = $webhook;
|
|
||||||
$this->initiator = user();
|
|
||||||
$this->initiatedTime = time();
|
|
||||||
|
|
||||||
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $event, $this->webhook, $detail, $this->initiator, $this->initiatedTime);
|
|
||||||
$this->webhookData = $themeResponse ?? WebhookFormatter::getDefault($event, $this->webhook, $detail, $this->initiator, $this->initiatedTime)->format();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the job.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function handle(HttpRequestService $http)
|
|
||||||
{
|
|
||||||
$lastError = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
(new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint);
|
|
||||||
|
|
||||||
$client = $http->buildClient($this->webhook->timeout, [
|
|
||||||
'connect_timeout' => 10,
|
|
||||||
'allow_redirects' => ['strict' => true],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $client->sendRequest($http->jsonRequest('POST', $this->webhook->endpoint, $this->webhookData));
|
|
||||||
$statusCode = $response->getStatusCode();
|
|
||||||
|
|
||||||
if ($statusCode >= 400) {
|
|
||||||
$lastError = "Response status from endpoint was {$statusCode}";
|
|
||||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$statusCode}");
|
|
||||||
}
|
|
||||||
} catch (\Exception $error) {
|
|
||||||
$lastError = $error->getMessage();
|
|
||||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->webhook->last_called_at = now();
|
|
||||||
if ($lastError) {
|
|
||||||
$this->webhook->last_errored_at = now();
|
|
||||||
$this->webhook->last_error = $lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->webhook->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Models;
|
|
||||||
|
|
||||||
use BookStack\App\Model;
|
|
||||||
use BookStack\Entities\Models\Entity;
|
|
||||||
use BookStack\Permissions\Models\JointPermission;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property string $type
|
|
||||||
* @property User $user
|
|
||||||
* @property Entity $loggable
|
|
||||||
* @property string $detail
|
|
||||||
* @property string $loggable_type
|
|
||||||
* @property int $loggable_id
|
|
||||||
* @property int $user_id
|
|
||||||
* @property Carbon $created_at
|
|
||||||
*/
|
|
||||||
class Activity extends Model
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the loggable model related to this activity.
|
|
||||||
* Currently only used for entities (previously entity_[id/type] columns).
|
|
||||||
* Could be used for others but will need an audit of uses where assumed
|
|
||||||
* to be entities.
|
|
||||||
*/
|
|
||||||
public function loggable(): MorphTo
|
|
||||||
{
|
|
||||||
return $this->morphTo('loggable');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the user this activity relates to.
|
|
||||||
*/
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function jointPermissions(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(JointPermission::class, 'entity_id', 'loggable_id')
|
|
||||||
->whereColumn('activities.loggable_type', '=', 'joint_permissions.entity_type');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns text from the language files, Looks up by using the activity key.
|
|
||||||
*/
|
|
||||||
public function getText(): string
|
|
||||||
{
|
|
||||||
return trans('activities.' . $this->type);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if this activity is intended to be for an entity.
|
|
||||||
*/
|
|
||||||
public function isForEntity(): bool
|
|
||||||
{
|
|
||||||
return Str::startsWith($this->type, [
|
|
||||||
'page_', 'chapter_', 'book_', 'bookshelf_',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if another Activity matches the general information of another.
|
|
||||||
*/
|
|
||||||
public function isSimilarTo(self $activityB): bool
|
|
||||||
{
|
|
||||||
return [$this->type, $this->loggable_type, $this->loggable_id] === [$activityB->type, $activityB->loggable_type, $activityB->loggable_id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Models;
|
|
||||||
|
|
||||||
use BookStack\App\Model;
|
|
||||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
|
||||||
use BookStack\Users\Models\OwnableInterface;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use BookStack\Util\HtmlContentFilter;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property string $text - Deprecated & now unused (#4821)
|
|
||||||
* @property string $html
|
|
||||||
* @property int|null $parent_id - Relates to local_id, not id
|
|
||||||
* @property int $local_id
|
|
||||||
* @property string $entity_type
|
|
||||||
* @property int $entity_id
|
|
||||||
* @property string $content_ref
|
|
||||||
* @property bool $archived
|
|
||||||
*/
|
|
||||||
class Comment extends Model implements Loggable, OwnableInterface
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
use HasCreatorAndUpdater;
|
|
||||||
|
|
||||||
protected $fillable = ['parent_id'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the entity that this comment belongs to.
|
|
||||||
*/
|
|
||||||
public function entity(): MorphTo
|
|
||||||
{
|
|
||||||
return $this->morphTo('entity');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the parent comment this is in reply to (if existing).
|
|
||||||
* @return BelongsTo<Comment, $this>
|
|
||||||
*/
|
|
||||||
public function parent(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
|
|
||||||
->where('entity_type', '=', $this->entity_type)
|
|
||||||
->where('entity_id', '=', $this->entity_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a comment has been updated since creation.
|
|
||||||
*/
|
|
||||||
public function isUpdated(): bool
|
|
||||||
{
|
|
||||||
return $this->updated_at->timestamp > $this->created_at->timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function logDescriptor(): string
|
|
||||||
{
|
|
||||||
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
|
|
||||||
}
|
|
||||||
|
|
||||||
public function safeHtml(): string
|
|
||||||
{
|
|
||||||
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Models;
|
|
||||||
|
|
||||||
use BookStack\App\Model;
|
|
||||||
use BookStack\Permissions\Models\JointPermission;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|
||||||
|
|
||||||
class Favourite extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = ['user_id'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the related model that can be favourited.
|
|
||||||
*/
|
|
||||||
public function favouritable(): MorphTo
|
|
||||||
{
|
|
||||||
return $this->morphTo();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function jointPermissions(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(JointPermission::class, 'entity_id', 'favouritable_id')
|
|
||||||
->whereColumn('favourites.favouritable_type', '=', 'joint_permissions.entity_type');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Models;
|
|
||||||
|
|
||||||
use BookStack\Activity\WatchLevels;
|
|
||||||
use BookStack\Permissions\Models\JointPermission;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property int $user_id
|
|
||||||
* @property int $watchable_id
|
|
||||||
* @property string $watchable_type
|
|
||||||
* @property int $level
|
|
||||||
* @property Carbon $created_at
|
|
||||||
* @property Carbon $updated_at
|
|
||||||
*/
|
|
||||||
class Watch extends Model
|
|
||||||
{
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
public function watchable(): MorphTo
|
|
||||||
{
|
|
||||||
return $this->morphTo();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function jointPermissions(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(JointPermission::class, 'entity_id', 'watchable_id')
|
|
||||||
->whereColumn('watches.watchable_type', '=', 'joint_permissions.entity_type');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLevelName(): string
|
|
||||||
{
|
|
||||||
return WatchLevels::levelValueToName($this->level);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function ignoring(): bool
|
|
||||||
{
|
|
||||||
return $this->level === WatchLevels::IGNORE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Models;
|
|
||||||
|
|
||||||
use BookStack\Activity\ActivityType;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property string $name
|
|
||||||
* @property string $endpoint
|
|
||||||
* @property Collection $trackedEvents
|
|
||||||
* @property bool $active
|
|
||||||
* @property int $timeout
|
|
||||||
* @property string $last_error
|
|
||||||
* @property Carbon $last_called_at
|
|
||||||
* @property Carbon $last_errored_at
|
|
||||||
*/
|
|
||||||
class Webhook extends Model implements Loggable
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = ['name', 'endpoint', 'timeout'];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'last_called_at' => 'datetime',
|
|
||||||
'last_errored_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Define the tracked event relation a webhook.
|
|
||||||
*/
|
|
||||||
public function trackedEvents(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(WebhookTrackedEvent::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the tracked events for a webhook from the given list of event types.
|
|
||||||
*/
|
|
||||||
public function updateTrackedEvents(array $events): void
|
|
||||||
{
|
|
||||||
$this->trackedEvents()->delete();
|
|
||||||
|
|
||||||
$eventsToStore = array_intersect($events, array_values(ActivityType::all()));
|
|
||||||
if (in_array('all', $events)) {
|
|
||||||
$eventsToStore = ['all'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$trackedEvents = [];
|
|
||||||
foreach ($eventsToStore as $event) {
|
|
||||||
$trackedEvents[] = new WebhookTrackedEvent(['event' => $event]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->trackedEvents()->saveMany($trackedEvents);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if this webhook tracks the given event.
|
|
||||||
*/
|
|
||||||
public function tracksEvent(string $event): bool
|
|
||||||
{
|
|
||||||
return $this->trackedEvents->pluck('event')->contains($event);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a URL for this webhook within the settings interface.
|
|
||||||
*/
|
|
||||||
public function getUrl(string $path = ''): string
|
|
||||||
{
|
|
||||||
return url('/settings/webhooks/' . $this->id . '/' . ltrim($path, '/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the string descriptor for this item.
|
|
||||||
*/
|
|
||||||
public function logDescriptor(): string
|
|
||||||
{
|
|
||||||
return "({$this->id}) {$this->name}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property int $webhook_id
|
|
||||||
* @property string $event
|
|
||||||
*/
|
|
||||||
class WebhookTrackedEvent extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = ['event'];
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications\Handlers;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Loggable;
|
|
||||||
use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
|
|
||||||
use BookStack\Entities\Models\Entity;
|
|
||||||
use BookStack\Permissions\Permission;
|
|
||||||
use BookStack\Permissions\PermissionApplicator;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
abstract class BaseNotificationHandler implements NotificationHandler
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param class-string<BaseActivityNotification> $notification
|
|
||||||
* @param int[] $userIds
|
|
||||||
*/
|
|
||||||
protected function sendNotificationToUserIds(string $notification, array $userIds, User $initiator, string|Loggable $detail, Entity $relatedModel): void
|
|
||||||
{
|
|
||||||
$users = User::query()->whereIn('id', array_unique($userIds))->get();
|
|
||||||
|
|
||||||
foreach ($users as $user) {
|
|
||||||
// Prevent sending to the user that initiated the activity
|
|
||||||
if ($user->id === $initiator->id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent sending of the user does not have notification permissions
|
|
||||||
if (!$user->can(Permission::ReceiveNotifications)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent sending if the user does not have access to the related content
|
|
||||||
$permissions = new PermissionApplicator($user);
|
|
||||||
if (!$permissions->checkOwnableUserAccess($relatedModel, 'view')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the notification
|
|
||||||
try {
|
|
||||||
$user->notify(new $notification($detail, $initiator));
|
|
||||||
} catch (\Exception $exception) {
|
|
||||||
Log::error("Failed to send email notification to user [id:{$user->id}] with error: {$exception->getMessage()}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications\Handlers;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Activity;
|
|
||||||
use BookStack\Activity\Models\Comment;
|
|
||||||
use BookStack\Activity\Models\Loggable;
|
|
||||||
use BookStack\Activity\Notifications\Messages\CommentCreationNotification;
|
|
||||||
use BookStack\Activity\Tools\EntityWatchers;
|
|
||||||
use BookStack\Activity\WatchLevels;
|
|
||||||
use BookStack\Entities\Models\Page;
|
|
||||||
use BookStack\Settings\UserNotificationPreferences;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
|
|
||||||
class CommentCreationNotificationHandler extends BaseNotificationHandler
|
|
||||||
{
|
|
||||||
public function handle(Activity $activity, Loggable|string $detail, User $user): void
|
|
||||||
{
|
|
||||||
if (!($detail instanceof Comment)) {
|
|
||||||
throw new \InvalidArgumentException("Detail for comment creation notifications must be a comment");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main watchers
|
|
||||||
/** @var Page $page */
|
|
||||||
$page = $detail->entity;
|
|
||||||
$watchers = new EntityWatchers($page, WatchLevels::COMMENTS);
|
|
||||||
$watcherIds = $watchers->getWatcherUserIds();
|
|
||||||
|
|
||||||
// Page owner if user preferences allow
|
|
||||||
if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
|
|
||||||
$userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
|
|
||||||
if ($userNotificationPrefs->notifyOnOwnPageComments()) {
|
|
||||||
$watcherIds[] = $page->owned_by;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parent comment creator if preferences allow
|
|
||||||
$parentComment = $detail->parent()->first();
|
|
||||||
if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
|
|
||||||
$parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
|
|
||||||
if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
|
|
||||||
$watcherIds[] = $parentComment->created_by;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->sendNotificationToUserIds(CommentCreationNotification::class, $watcherIds, $user, $detail, $page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications\Handlers;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Activity;
|
|
||||||
use BookStack\Activity\Models\Loggable;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
|
|
||||||
interface NotificationHandler
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run this handler.
|
|
||||||
* Provides the activity, related activity detail/model
|
|
||||||
* along with the user that triggered the activity.
|
|
||||||
*/
|
|
||||||
public function handle(Activity $activity, string|Loggable $detail, User $user): void;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications\Handlers;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Activity;
|
|
||||||
use BookStack\Activity\Models\Loggable;
|
|
||||||
use BookStack\Activity\Notifications\Messages\PageCreationNotification;
|
|
||||||
use BookStack\Activity\Tools\EntityWatchers;
|
|
||||||
use BookStack\Activity\WatchLevels;
|
|
||||||
use BookStack\Entities\Models\Page;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
|
|
||||||
class PageCreationNotificationHandler extends BaseNotificationHandler
|
|
||||||
{
|
|
||||||
public function handle(Activity $activity, Loggable|string $detail, User $user): void
|
|
||||||
{
|
|
||||||
if (!($detail instanceof Page)) {
|
|
||||||
throw new \InvalidArgumentException("Detail for page create notifications must be a page");
|
|
||||||
}
|
|
||||||
|
|
||||||
$watchers = new EntityWatchers($detail, WatchLevels::NEW);
|
|
||||||
$this->sendNotificationToUserIds(PageCreationNotification::class, $watchers->getWatcherUserIds(), $user, $detail, $detail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications\Handlers;
|
|
||||||
|
|
||||||
use BookStack\Activity\ActivityType;
|
|
||||||
use BookStack\Activity\Models\Activity;
|
|
||||||
use BookStack\Activity\Models\Loggable;
|
|
||||||
use BookStack\Activity\Notifications\Messages\PageUpdateNotification;
|
|
||||||
use BookStack\Activity\Tools\EntityWatchers;
|
|
||||||
use BookStack\Activity\WatchLevels;
|
|
||||||
use BookStack\Entities\Models\Page;
|
|
||||||
use BookStack\Settings\UserNotificationPreferences;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
|
|
||||||
class PageUpdateNotificationHandler extends BaseNotificationHandler
|
|
||||||
{
|
|
||||||
public function handle(Activity $activity, Loggable|string $detail, User $user): void
|
|
||||||
{
|
|
||||||
if (!($detail instanceof Page)) {
|
|
||||||
throw new \InvalidArgumentException("Detail for page update notifications must be a page");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the last update from activity
|
|
||||||
/** @var ?Activity $lastUpdate */
|
|
||||||
$lastUpdate = $detail->activity()
|
|
||||||
->where('type', '=', ActivityType::PAGE_UPDATE)
|
|
||||||
->where('id', '!=', $activity->id)
|
|
||||||
->latest('created_at')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
// Return if the same user has already updated the page in the last 15 mins
|
|
||||||
if ($lastUpdate && $lastUpdate->user_id === $user->id) {
|
|
||||||
if ($lastUpdate->created_at->gt(now()->subMinutes(15))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get active watchers
|
|
||||||
$watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
|
|
||||||
$watcherIds = $watchers->getWatcherUserIds();
|
|
||||||
|
|
||||||
// Add page owner if preferences allow
|
|
||||||
if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
|
|
||||||
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
|
|
||||||
if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
|
|
||||||
$watcherIds[] = $detail->owned_by;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->sendNotificationToUserIds(PageUpdateNotification::class, $watcherIds, $user, $detail, $detail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications\MessageParts;
|
|
||||||
|
|
||||||
use BookStack\Entities\Models\Entity;
|
|
||||||
use Illuminate\Contracts\Support\Htmlable;
|
|
||||||
use Stringable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A link to a specific entity in the system, with the text showing its name.
|
|
||||||
*/
|
|
||||||
class EntityLinkMessageLine implements Htmlable, Stringable
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected Entity $entity,
|
|
||||||
protected int $nameLength = 120,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toHtml(): string
|
|
||||||
{
|
|
||||||
return '<a href="' . e($this->entity->getUrl()) . '">' . e($this->entity->getShortName($this->nameLength)) . '</a>';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function __toString(): string
|
|
||||||
{
|
|
||||||
return "{$this->entity->getShortName($this->nameLength)} ({$this->entity->getUrl()})";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications\MessageParts;
|
|
||||||
|
|
||||||
use BookStack\Entities\Models\Entity;
|
|
||||||
use Illuminate\Contracts\Support\Htmlable;
|
|
||||||
use Stringable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A link to a specific entity in the system, with the text showing its name.
|
|
||||||
*/
|
|
||||||
class EntityPathMessageLine implements Htmlable, Stringable
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var EntityLinkMessageLine[]
|
|
||||||
*/
|
|
||||||
protected array $entityLinks;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
protected array $entities
|
|
||||||
) {
|
|
||||||
$this->entityLinks = array_map(fn (Entity $entity) => new EntityLinkMessageLine($entity, 24), $this->entities);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toHtml(): string
|
|
||||||
{
|
|
||||||
$entityHtmls = array_map(fn (EntityLinkMessageLine $line) => $line->toHtml(), $this->entityLinks);
|
|
||||||
return implode(' > ', $entityHtmls);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function __toString(): string
|
|
||||||
{
|
|
||||||
return implode(' > ', $this->entityLinks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications\MessageParts;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Support\Htmlable;
|
|
||||||
use Stringable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A line of text with linked text included, intended for use
|
|
||||||
* in MailMessages. The line should have a ':link' placeholder for
|
|
||||||
* where the link should be inserted within the line.
|
|
||||||
*/
|
|
||||||
class LinkedMailMessageLine implements Htmlable, Stringable
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected string $url,
|
|
||||||
protected string $line,
|
|
||||||
protected string $linkText,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toHtml(): string
|
|
||||||
{
|
|
||||||
$link = '<a href="' . e($this->url) . '">' . e($this->linkText) . '</a>';
|
|
||||||
return str_replace(':link', $link, e($this->line));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function __toString(): string
|
|
||||||
{
|
|
||||||
$link = "{$this->linkText} ({$this->url})";
|
|
||||||
return str_replace(':link', $link, $this->line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications\MessageParts;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Support\Htmlable;
|
|
||||||
use Stringable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A bullet point list of content, where the keys of the given list array
|
|
||||||
* are bolded header elements, and the values follow.
|
|
||||||
*/
|
|
||||||
class ListMessageLine implements Htmlable, Stringable
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected array $list
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toHtml(): string
|
|
||||||
{
|
|
||||||
$list = [];
|
|
||||||
foreach ($this->list as $header => $content) {
|
|
||||||
$list[] = '<strong>' . e($header) . '</strong> ' . e($content);
|
|
||||||
}
|
|
||||||
return implode("<br>\n", $list);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function __toString(): string
|
|
||||||
{
|
|
||||||
$list = [];
|
|
||||||
foreach ($this->list as $header => $content) {
|
|
||||||
$list[] = $header . ' ' . $content;
|
|
||||||
}
|
|
||||||
return implode("\n", $list);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications\Messages;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Loggable;
|
|
||||||
use BookStack\Activity\Notifications\MessageParts\EntityPathMessageLine;
|
|
||||||
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
|
|
||||||
use BookStack\App\MailNotification;
|
|
||||||
use BookStack\Entities\Models\Entity;
|
|
||||||
use BookStack\Entities\Models\Page;
|
|
||||||
use BookStack\Permissions\PermissionApplicator;
|
|
||||||
use BookStack\Translation\LocaleDefinition;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
|
|
||||||
abstract class BaseActivityNotification extends MailNotification
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
protected Loggable|string $detail,
|
|
||||||
protected User $user,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the array representation of the notification.
|
|
||||||
*
|
|
||||||
* @param mixed $notifiable
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function toArray($notifiable)
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'activity_detail' => $this->detail,
|
|
||||||
'activity_creator' => $this->user,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the common reason footer line used in mail messages.
|
|
||||||
*/
|
|
||||||
protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine
|
|
||||||
{
|
|
||||||
return new LinkedMailMessageLine(
|
|
||||||
url('/my-account/notifications'),
|
|
||||||
$locale->trans('notifications.footer_reason'),
|
|
||||||
$locale->trans('notifications.footer_reason_link'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a line which provides the book > chapter path to a page.
|
|
||||||
* Takes into account visibility of these parent items.
|
|
||||||
* Returns null if no path items can be used.
|
|
||||||
*/
|
|
||||||
protected function buildPagePathLine(Page $page, User $notifiable): ?EntityPathMessageLine
|
|
||||||
{
|
|
||||||
$permissions = new PermissionApplicator($notifiable);
|
|
||||||
|
|
||||||
$path = array_filter([$page->book, $page->chapter], function (?Entity $entity) use ($permissions) {
|
|
||||||
return !is_null($entity) && $permissions->checkOwnableUserAccess($entity, 'view');
|
|
||||||
});
|
|
||||||
|
|
||||||
return empty($path) ? null : new EntityPathMessageLine($path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications\Messages;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Comment;
|
|
||||||
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
|
|
||||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
|
||||||
use BookStack\Entities\Models\Page;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
|
||||||
|
|
||||||
class CommentCreationNotification extends BaseActivityNotification
|
|
||||||
{
|
|
||||||
public function toMail(User $notifiable): MailMessage
|
|
||||||
{
|
|
||||||
/** @var Comment $comment */
|
|
||||||
$comment = $this->detail;
|
|
||||||
/** @var Page $page */
|
|
||||||
$page = $comment->entity;
|
|
||||||
|
|
||||||
$locale = $notifiable->getLocale();
|
|
||||||
|
|
||||||
$listLines = array_filter([
|
|
||||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
|
||||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
|
||||||
$locale->trans('notifications.detail_commenter') => $this->user->name,
|
|
||||||
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->newMailMessage($locale)
|
|
||||||
->subject($locale->trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()]))
|
|
||||||
->line($locale->trans('notifications.new_comment_intro', ['appName' => setting('app-name')]))
|
|
||||||
->line(new ListMessageLine($listLines))
|
|
||||||
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
|
|
||||||
->line($this->buildReasonFooterLine($locale));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications\Messages;
|
|
||||||
|
|
||||||
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
|
|
||||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
|
||||||
use BookStack\Entities\Models\Page;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
|
||||||
|
|
||||||
class PageCreationNotification extends BaseActivityNotification
|
|
||||||
{
|
|
||||||
public function toMail(User $notifiable): MailMessage
|
|
||||||
{
|
|
||||||
/** @var Page $page */
|
|
||||||
$page = $this->detail;
|
|
||||||
|
|
||||||
$locale = $notifiable->getLocale();
|
|
||||||
|
|
||||||
$listLines = array_filter([
|
|
||||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
|
||||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
|
||||||
$locale->trans('notifications.detail_created_by') => $this->user->name,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->newMailMessage($locale)
|
|
||||||
->subject($locale->trans('notifications.new_page_subject', ['pageName' => $page->getShortName()]))
|
|
||||||
->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')]))
|
|
||||||
->line(new ListMessageLine($listLines))
|
|
||||||
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
|
|
||||||
->line($this->buildReasonFooterLine($locale));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications\Messages;
|
|
||||||
|
|
||||||
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
|
|
||||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
|
||||||
use BookStack\Entities\Models\Page;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
|
||||||
|
|
||||||
class PageUpdateNotification extends BaseActivityNotification
|
|
||||||
{
|
|
||||||
public function toMail(User $notifiable): MailMessage
|
|
||||||
{
|
|
||||||
/** @var Page $page */
|
|
||||||
$page = $this->detail;
|
|
||||||
|
|
||||||
$locale = $notifiable->getLocale();
|
|
||||||
|
|
||||||
$listLines = array_filter([
|
|
||||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
|
||||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
|
||||||
$locale->trans('notifications.detail_updated_by') => $this->user->name,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->newMailMessage($locale)
|
|
||||||
->subject($locale->trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()]))
|
|
||||||
->line($locale->trans('notifications.updated_page_intro', ['appName' => setting('app-name')]))
|
|
||||||
->line(new ListMessageLine($listLines))
|
|
||||||
->line($locale->trans('notifications.updated_page_debounce'))
|
|
||||||
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
|
|
||||||
->line($this->buildReasonFooterLine($locale));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Notifications;
|
|
||||||
|
|
||||||
use BookStack\Activity\ActivityType;
|
|
||||||
use BookStack\Activity\Models\Activity;
|
|
||||||
use BookStack\Activity\Models\Loggable;
|
|
||||||
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
|
|
||||||
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
|
|
||||||
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
|
|
||||||
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
|
|
||||||
class NotificationManager
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var class-string<NotificationHandler>[]
|
|
||||||
*/
|
|
||||||
protected array $handlers = [];
|
|
||||||
|
|
||||||
public function handle(Activity $activity, string|Loggable $detail, User $user): void
|
|
||||||
{
|
|
||||||
$activityType = $activity->type;
|
|
||||||
$handlersToRun = $this->handlers[$activityType] ?? [];
|
|
||||||
foreach ($handlersToRun as $handlerClass) {
|
|
||||||
/** @var NotificationHandler $handler */
|
|
||||||
$handler = new $handlerClass();
|
|
||||||
$handler->handle($activity, $detail, $user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param class-string<NotificationHandler> $handlerClass
|
|
||||||
*/
|
|
||||||
public function registerHandler(string $activityType, string $handlerClass): void
|
|
||||||
{
|
|
||||||
if (!isset($this->handlers[$activityType])) {
|
|
||||||
$this->handlers[$activityType] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!in_array($handlerClass, $this->handlers[$activityType])) {
|
|
||||||
$this->handlers[$activityType][] = $handlerClass;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function loadDefaultHandlers(): void
|
|
||||||
{
|
|
||||||
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
|
|
||||||
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
|
|
||||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Queries;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\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,138 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Tag;
|
|
||||||
use BookStack\Entities\Models\Entity;
|
|
||||||
use BookStack\Permissions\PermissionApplicator;
|
|
||||||
use BookStack\Util\SimpleListOptions;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class TagRepo
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected PermissionApplicator $permissions
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a query against all tags in the system.
|
|
||||||
*/
|
|
||||||
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
|
|
||||||
{
|
|
||||||
$searchTerm = $listOptions->getSearch();
|
|
||||||
$sort = $listOptions->getSort();
|
|
||||||
if ($sort === 'name' && $nameFilter) {
|
|
||||||
$sort = 'value';
|
|
||||||
}
|
|
||||||
|
|
||||||
$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(entity_type = \'page\', 1, 0)) as page_count'),
|
|
||||||
DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'),
|
|
||||||
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
|
|
||||||
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
|
|
||||||
])
|
|
||||||
->orderBy($sort, $listOptions->getOrder())
|
|
||||||
->whereHas('entity');
|
|
||||||
|
|
||||||
if ($nameFilter) {
|
|
||||||
$query->where('name', '=', $nameFilter);
|
|
||||||
$query->groupBy('value');
|
|
||||||
} elseif ($searchTerm) {
|
|
||||||
$query->groupBy('name', 'value');
|
|
||||||
} else {
|
|
||||||
$query->groupBy('name');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($searchTerm) {
|
|
||||||
$query->where(function (Builder $query) use ($searchTerm) {
|
|
||||||
$query->where('name', 'like', '%' . $searchTerm . '%')
|
|
||||||
->orWhere('value', 'like', '%' . $searchTerm . '%');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->permissions->restrictEntityRelationQuery($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
|
|
||||||
{
|
|
||||||
$query = Tag::query()
|
|
||||||
->select('*', DB::raw('count(*) as count'))
|
|
||||||
->groupBy('name');
|
|
||||||
|
|
||||||
if ($searchTerm) {
|
|
||||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'asc');
|
|
||||||
} else {
|
|
||||||
$query = $query->orderBy('count', 'desc')->take(50);
|
|
||||||
}
|
|
||||||
|
|
||||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
|
||||||
|
|
||||||
return $query->pluck('name');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tag value suggestions from scanning existing tag values.
|
|
||||||
* 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
|
|
||||||
{
|
|
||||||
$query = Tag::query()
|
|
||||||
->select('*', DB::raw('count(*) as count'))
|
|
||||||
->where('value', '!=', '')
|
|
||||||
->groupBy('value');
|
|
||||||
|
|
||||||
if ($searchTerm) {
|
|
||||||
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
|
|
||||||
} else {
|
|
||||||
$query = $query->orderBy('count', 'desc')->take(50);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tagName) {
|
|
||||||
$query = $query->where('name', '=', $tagName);
|
|
||||||
}
|
|
||||||
|
|
||||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
|
||||||
|
|
||||||
return $query->pluck('value');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save an array of tags to an entity.
|
|
||||||
*/
|
|
||||||
public function saveTagsToEntity(Entity $entity, array $tags = []): iterable
|
|
||||||
{
|
|
||||||
$entity->tags()->delete();
|
|
||||||
|
|
||||||
$newTags = collect($tags)->filter(function ($tag) {
|
|
||||||
return boolval(trim($tag['name']));
|
|
||||||
})->map(function ($tag) {
|
|
||||||
return $this->newInstanceFromInput($tag);
|
|
||||||
})->all();
|
|
||||||
|
|
||||||
return $entity->tags()->saveMany($newTags);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new Tag instance from user input.
|
|
||||||
* Input must be an array with a 'name' and an optional 'value' key.
|
|
||||||
*/
|
|
||||||
protected function newInstanceFromInput(array $input): Tag
|
|
||||||
{
|
|
||||||
return new Tag([
|
|
||||||
'name' => trim($input['name']),
|
|
||||||
'value' => trim($input['value'] ?? ''),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Tools;
|
|
||||||
|
|
||||||
use BookStack\Activity\DispatchWebhookJob;
|
|
||||||
use BookStack\Activity\Models\Activity;
|
|
||||||
use BookStack\Activity\Models\Loggable;
|
|
||||||
use BookStack\Activity\Models\Webhook;
|
|
||||||
use BookStack\Activity\Notifications\NotificationManager;
|
|
||||||
use BookStack\Entities\Models\Entity;
|
|
||||||
use BookStack\Facades\Theme;
|
|
||||||
use BookStack\Theming\ThemeEvents;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class ActivityLogger
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected NotificationManager $notifications
|
|
||||||
) {
|
|
||||||
$this->notifications->loadDefaultHandlers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a generic activity event to the database.
|
|
||||||
*/
|
|
||||||
public function add(string $type, string|Loggable $detail = ''): void
|
|
||||||
{
|
|
||||||
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
|
|
||||||
|
|
||||||
$activity = $this->newActivityForUser($type);
|
|
||||||
$activity->detail = $detailToStore;
|
|
||||||
|
|
||||||
if ($detail instanceof Entity) {
|
|
||||||
$activity->loggable_id = $detail->id;
|
|
||||||
$activity->loggable_type = $detail->getMorphClass();
|
|
||||||
}
|
|
||||||
|
|
||||||
$activity->save();
|
|
||||||
|
|
||||||
$this->setNotification($type);
|
|
||||||
$this->dispatchWebhooks($type, $detail);
|
|
||||||
$this->notifications->handle($activity, $detail, user());
|
|
||||||
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a new activity instance for the current user.
|
|
||||||
*/
|
|
||||||
protected function newActivityForUser(string $type): Activity
|
|
||||||
{
|
|
||||||
return (new Activity())->forceFill([
|
|
||||||
'type' => strtolower($type),
|
|
||||||
'user_id' => user()->id,
|
|
||||||
'ip' => IpFormatter::fromCurrentRequest()->format(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes the entity attachment from each of its activities
|
|
||||||
* and instead uses the 'extra' field with the entities name.
|
|
||||||
* Used when an entity is deleted.
|
|
||||||
*/
|
|
||||||
public function removeEntity(Entity $entity): void
|
|
||||||
{
|
|
||||||
$entity->activity()->update([
|
|
||||||
'detail' => $entity->name,
|
|
||||||
'loggable_id' => null,
|
|
||||||
'loggable_type' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flashes a notification message to the session if an appropriate message is available.
|
|
||||||
*/
|
|
||||||
protected function setNotification(string $type): void
|
|
||||||
{
|
|
||||||
$notificationTextKey = 'activities.' . $type . '_notification';
|
|
||||||
if (trans()->has($notificationTextKey)) {
|
|
||||||
$message = trans($notificationTextKey);
|
|
||||||
session()->flash('success', $message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function dispatchWebhooks(string $type, string|Loggable $detail): void
|
|
||||||
{
|
|
||||||
$webhooks = Webhook::query()
|
|
||||||
->whereHas('trackedEvents', function (Builder $query) use ($type) {
|
|
||||||
$query->where('event', '=', $type)
|
|
||||||
->orWhere('event', '=', 'all');
|
|
||||||
})
|
|
||||||
->where('active', '=', true)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($webhooks as $webhook) {
|
|
||||||
dispatch(new DispatchWebhookJob($webhook, $type, $detail));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log out a failed login attempt, Providing the given username
|
|
||||||
* as part of the message if the '%u' string is used.
|
|
||||||
*/
|
|
||||||
public function logFailedLogin(string $username): void
|
|
||||||
{
|
|
||||||
$message = config('logging.failed_login.message');
|
|
||||||
if (!$message) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = str_replace('%u', $username, $message);
|
|
||||||
$channel = config('logging.failed_login.channel');
|
|
||||||
Log::channel($channel)->warning($message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Tools;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Comment;
|
|
||||||
use BookStack\Entities\Models\Page;
|
|
||||||
use BookStack\Permissions\Permission;
|
|
||||||
|
|
||||||
class CommentTree
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The built nested tree structure array.
|
|
||||||
* @var CommentTreeNode[]
|
|
||||||
*/
|
|
||||||
protected array $tree;
|
|
||||||
protected array $comments;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
protected Page $page
|
|
||||||
) {
|
|
||||||
$this->comments = $this->loadComments();
|
|
||||||
$this->tree = $this->createTree($this->comments);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function enabled(): bool
|
|
||||||
{
|
|
||||||
return !setting('app-disable-comments');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function empty(): bool
|
|
||||||
{
|
|
||||||
return count($this->getActive()) === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function count(): int
|
|
||||||
{
|
|
||||||
return count($this->comments);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getActive(): array
|
|
||||||
{
|
|
||||||
return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function activeThreadCount(): int
|
|
||||||
{
|
|
||||||
return count($this->getActive());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getArchived(): array
|
|
||||||
{
|
|
||||||
return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function archivedThreadCount(): int
|
|
||||||
{
|
|
||||||
return count($this->getArchived());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCommentNodeForId(int $commentId): ?CommentTreeNode
|
|
||||||
{
|
|
||||||
foreach ($this->tree as $node) {
|
|
||||||
if ($node->comment->id === $commentId) {
|
|
||||||
return $node;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canUpdateAny(): bool
|
|
||||||
{
|
|
||||||
foreach ($this->comments as $comment) {
|
|
||||||
if (userCan(Permission::CommentUpdate, $comment)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Comment[] $comments
|
|
||||||
* @return CommentTreeNode[]
|
|
||||||
*/
|
|
||||||
protected function createTree(array $comments): array
|
|
||||||
{
|
|
||||||
$byId = [];
|
|
||||||
foreach ($comments as $comment) {
|
|
||||||
$byId[$comment->local_id] = $comment;
|
|
||||||
}
|
|
||||||
|
|
||||||
$childMap = [];
|
|
||||||
foreach ($comments as $comment) {
|
|
||||||
$parent = $comment->parent_id;
|
|
||||||
if (is_null($parent) || !isset($byId[$parent])) {
|
|
||||||
$parent = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($childMap[$parent])) {
|
|
||||||
$childMap[$parent] = [];
|
|
||||||
}
|
|
||||||
$childMap[$parent][] = $comment->local_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tree = [];
|
|
||||||
foreach ($childMap[0] ?? [] as $childId) {
|
|
||||||
$tree[] = $this->createTreeNodeForId($childId, 0, $byId, $childMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tree;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function createTreeNodeForId(int $id, int $depth, array &$byId, array &$childMap): CommentTreeNode
|
|
||||||
{
|
|
||||||
$childIds = $childMap[$id] ?? [];
|
|
||||||
$children = [];
|
|
||||||
|
|
||||||
foreach ($childIds as $childId) {
|
|
||||||
$children[] = $this->createTreeNodeForId($childId, $depth + 1, $byId, $childMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new CommentTreeNode($byId[$id], $depth, $children);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function loadComments(): array
|
|
||||||
{
|
|
||||||
if (!$this->enabled()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->page->comments()
|
|
||||||
->with('createdBy')
|
|
||||||
->get()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Tools;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Comment;
|
|
||||||
|
|
||||||
class CommentTreeNode
|
|
||||||
{
|
|
||||||
public Comment $comment;
|
|
||||||
public int $depth;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var CommentTreeNode[]
|
|
||||||
*/
|
|
||||||
public array $children;
|
|
||||||
|
|
||||||
public function __construct(Comment $comment, int $depth, array $children)
|
|
||||||
{
|
|
||||||
$this->comment = $comment;
|
|
||||||
$this->depth = $depth;
|
|
||||||
$this->children = $children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Tools;
|
|
||||||
|
|
||||||
use BookStack\Activity\Models\Watch;
|
|
||||||
use BookStack\Entities\Models\BookChild;
|
|
||||||
use BookStack\Entities\Models\Entity;
|
|
||||||
use BookStack\Entities\Models\Page;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class EntityWatchers
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var int[]
|
|
||||||
*/
|
|
||||||
protected array $watchers = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var int[]
|
|
||||||
*/
|
|
||||||
protected array $ignorers = [];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
protected Entity $entity,
|
|
||||||
protected int $watchLevel,
|
|
||||||
) {
|
|
||||||
$this->build();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getWatcherUserIds(): array
|
|
||||||
{
|
|
||||||
return $this->watchers;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isUserIgnoring(int $userId): bool
|
|
||||||
{
|
|
||||||
return in_array($userId, $this->ignorers);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function build(): void
|
|
||||||
{
|
|
||||||
$watches = $this->getRelevantWatches();
|
|
||||||
|
|
||||||
// Sort before de-duping, so that the order looped below follows book -> chapter -> page ordering
|
|
||||||
usort($watches, function (Watch $watchA, Watch $watchB) {
|
|
||||||
$entityTypeDiff = $watchA->watchable_type <=> $watchB->watchable_type;
|
|
||||||
return $entityTypeDiff === 0 ? ($watchA->user_id <=> $watchB->user_id) : $entityTypeDiff;
|
|
||||||
});
|
|
||||||
|
|
||||||
// De-dupe by user id to get their most relevant level
|
|
||||||
$levelByUserId = [];
|
|
||||||
foreach ($watches as $watch) {
|
|
||||||
$levelByUserId[$watch->user_id] = $watch->level;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate the class arrays
|
|
||||||
$this->watchers = array_keys(array_filter($levelByUserId, fn(int $level) => $level >= $this->watchLevel));
|
|
||||||
$this->ignorers = array_keys(array_filter($levelByUserId, fn(int $level) => $level === 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Watch[]
|
|
||||||
*/
|
|
||||||
protected function getRelevantWatches(): array
|
|
||||||
{
|
|
||||||
/** @var Entity[] $entitiesInvolved */
|
|
||||||
$entitiesInvolved = array_filter([
|
|
||||||
$this->entity,
|
|
||||||
$this->entity instanceof BookChild ? $this->entity->book : null,
|
|
||||||
$this->entity instanceof Page ? $this->entity->chapter : null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$query = Watch::query()->where(function (Builder $query) use ($entitiesInvolved) {
|
|
||||||
foreach ($entitiesInvolved as $entity) {
|
|
||||||
$query->orWhere(function (Builder $query) use ($entity) {
|
|
||||||
$query->where('watchable_type', '=', $entity->getMorphClass())
|
|
||||||
->where('watchable_id', '=', $entity->id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return $query->get([
|
|
||||||
'level', 'watchable_id', 'watchable_type', 'user_id'
|
|
||||||
])->all();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\Activity\Tools;
|
|
||||||
|
|
||||||
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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user