mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 16:49:47 +03:00
Compare commits
169 Commits
user_permi
...
v0.28.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
29
.env.example
29
.env.example
@@ -1,24 +1,14 @@
|
||||
# This file, when named as ".env" in the root of your BookStack install
|
||||
# folder, is used for the core configuration of the application.
|
||||
# By default this file contains the most common required options but
|
||||
# a full list of options can be found in the '.env.example.complete' file.
|
||||
|
||||
# NOTE: If any of your values contain a space or a hash you will need to
|
||||
# wrap the entire value in quotes. (eg. MAIL_FROM_NAME="BookStack Mailer")
|
||||
|
||||
# Application key
|
||||
# Used for encryption where needed.
|
||||
# Run `php artisan key:generate` to generate a valid key.
|
||||
APP_KEY=SomeRandomString
|
||||
|
||||
# Application URL
|
||||
# Remove the hash below and set a URL if using BookStack behind
|
||||
# a proxy, if using a third-party authentication option.
|
||||
# This must be the root URL that you want to host BookStack on.
|
||||
# All URLs in BookStack will be generated using this value
|
||||
# to ensure URLs generated are consistent and secure.
|
||||
# If you change this in the future you may need to run a command
|
||||
# to update stored URLs in the database. Command example:
|
||||
# php artisan bookstack:update-url https://old.example.com https://new.example.com
|
||||
APP_URL=https://example.com
|
||||
# All URL's in BookStack will be generated using this value.
|
||||
#APP_URL=https://example.com
|
||||
|
||||
# Database details
|
||||
DB_HOST=localhost
|
||||
@@ -27,18 +17,15 @@ DB_USERNAME=database_username
|
||||
DB_PASSWORD=database_user_password
|
||||
|
||||
# Mail system to use
|
||||
# Can be 'smtp' or 'sendmail'
|
||||
# Can be 'smtp', 'mail' or 'sendmail'
|
||||
MAIL_DRIVER=smtp
|
||||
|
||||
# Mail sender details
|
||||
MAIL_FROM_NAME="BookStack"
|
||||
MAIL_FROM=bookstack@example.com
|
||||
|
||||
# SMTP mail options
|
||||
# These settings can be checked using the "Send a Test Email"
|
||||
# feature found in the "Settings > Maintenance" area of the system.
|
||||
MAIL_HOST=localhost
|
||||
MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
|
||||
|
||||
# A full list of options can be found in the '.env.example.complete' file.
|
||||
@@ -42,14 +42,6 @@ APP_TIMEZONE=UTC
|
||||
# overrides can be made. Defaults to disabled.
|
||||
APP_THEME=false
|
||||
|
||||
# Trusted proxies
|
||||
# Used to indicate trust of systems that proxy to the application so
|
||||
# certain header values (Such as "X-Forwarded-For") can be used from the
|
||||
# incoming proxy request to provide origin detail.
|
||||
# Set to an IP address, or multiple comma seperated IP addresses.
|
||||
# Can alternatively be set to "*" to trust all proxy addresses.
|
||||
APP_PROXIES=null
|
||||
|
||||
# Database details
|
||||
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
|
||||
DB_HOST=localhost
|
||||
@@ -58,15 +50,8 @@ DB_DATABASE=database_database
|
||||
DB_USERNAME=database_username
|
||||
DB_PASSWORD=database_user_password
|
||||
|
||||
# MySQL specific connection options
|
||||
# Path to Certificate Authority (CA) certificate file for your MySQL instance.
|
||||
# When this option is used host name identity verification will be performed
|
||||
# which checks the hostname, used by the client, against names within the
|
||||
# certificate itself (Common Name or Subject Alternative Name).
|
||||
MYSQL_ATTR_SSL_CA="/path/to/ca.pem"
|
||||
|
||||
# Mail system to use
|
||||
# Can be 'smtp' or 'sendmail'
|
||||
# Can be 'smtp', 'mail' or 'sendmail'
|
||||
MAIL_DRIVER=smtp
|
||||
|
||||
# Mail sending options
|
||||
@@ -107,7 +92,8 @@ MEMCACHED_SERVERS=127.0.0.1:11211:100
|
||||
REDIS_SERVERS=127.0.0.1:6379:0
|
||||
|
||||
# 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
|
||||
|
||||
# Storage system to use
|
||||
@@ -140,13 +126,9 @@ STORAGE_S3_ENDPOINT=https://my-custom-s3-compatible.service.com:8001
|
||||
STORAGE_URL=false
|
||||
|
||||
# Authentication method to use
|
||||
# Can be 'standard', 'ldap', 'saml2' or 'oidc'
|
||||
# Can be 'standard', 'ldap' or 'saml2'
|
||||
AUTH_METHOD=standard
|
||||
|
||||
# Automatically initiate login via external auth system if it's the only auth method.
|
||||
# Works with saml2 or oidc auth methods.
|
||||
AUTH_AUTO_INITIATE=false
|
||||
|
||||
# Social authentication configuration
|
||||
# All disabled by default.
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/third-party-auth/
|
||||
@@ -213,12 +195,10 @@ LDAP_DN=false
|
||||
LDAP_PASS=false
|
||||
LDAP_USER_FILTER=false
|
||||
LDAP_VERSION=false
|
||||
LDAP_START_TLS=false
|
||||
LDAP_TLS_INSECURE=false
|
||||
LDAP_ID_ATTRIBUTE=uid
|
||||
LDAP_EMAIL_ATTRIBUTE=mail
|
||||
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
|
||||
LDAP_THUMBNAIL_ATTRIBUTE=null
|
||||
LDAP_FOLLOW_REFERRALS=true
|
||||
LDAP_DUMP_USER_DETAILS=false
|
||||
|
||||
@@ -227,7 +207,6 @@ LDAP_DUMP_USER_DETAILS=false
|
||||
LDAP_USER_TO_GROUPS=false
|
||||
LDAP_GROUP_ATTRIBUTE="memberOf"
|
||||
LDAP_REMOVE_FROM_GROUPS=false
|
||||
LDAP_DUMP_USER_GROUPS=false
|
||||
|
||||
# SAML authentication configuration
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
|
||||
@@ -242,9 +221,6 @@ SAML2_IDP_x509=null
|
||||
SAML2_ONELOGIN_OVERRIDES=null
|
||||
SAML2_DUMP_USER_DETAILS=false
|
||||
SAML2_AUTOLOAD_METADATA=false
|
||||
SAML2_IDP_AUTHNCONTEXT=true
|
||||
SAML2_SP_x509=null
|
||||
SAML2_SP_x509_KEY=null
|
||||
|
||||
# SAML group sync configuration
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
|
||||
@@ -252,23 +228,6 @@ SAML2_USER_TO_GROUPS=false
|
||||
SAML2_GROUP_ATTRIBUTE=group
|
||||
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_ADDITIONAL_SCOPES=null
|
||||
OIDC_DUMP_USER_DETAILS=false
|
||||
OIDC_USER_TO_GROUPS=false
|
||||
OIDC_GROUPS_CLAIM=groups
|
||||
OIDC_REMOVE_FROM_GROUPS=false
|
||||
|
||||
# Disable default third-party services such as Gravatar and Draw.IO
|
||||
# Service-specific options will override this option
|
||||
DISABLE_EXTERNAL_SERVICES=false
|
||||
@@ -279,44 +238,19 @@ DISABLE_EXTERNAL_SERVICES=false
|
||||
# Example: AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon
|
||||
AVATAR_URL=
|
||||
|
||||
# Enable diagrams.net integration
|
||||
# Can simply be true/false to enable/disable the integration.
|
||||
# Alternatively, It can be URL to the diagrams.net instance you want to use.
|
||||
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1&configure=1
|
||||
# Enable Draw.io integration
|
||||
DRAWIO=true
|
||||
|
||||
# Default item listing view
|
||||
# Used for public visitors and user's without a preference.
|
||||
# Can be 'list' or 'grid'.
|
||||
# Used for public visitors and user's without a preference
|
||||
# Can be 'list' or 'grid'
|
||||
APP_VIEWS_BOOKS=list
|
||||
APP_VIEWS_BOOKSHELVES=grid
|
||||
APP_VIEWS_BOOKSHELF=grid
|
||||
|
||||
# Use dark mode by default
|
||||
# Will be overriden by any user/session preference.
|
||||
APP_DEFAULT_DARK_MODE=false
|
||||
|
||||
# Page revision limit
|
||||
# Number of page revisions to keep in the system before deleting old revisions.
|
||||
# If set to 'false' a limit will not be enforced.
|
||||
REVISION_LIMIT=100
|
||||
|
||||
# Recycle Bin Lifetime
|
||||
# The number of days that content will remain in the recycle bin before
|
||||
# being considered for auto-removal. It is not a guarantee that content will
|
||||
# be removed after this time.
|
||||
# Set to 0 for no recycle bin functionality.
|
||||
# Set to -1 for unlimited recycle bin lifetime.
|
||||
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
|
||||
REVISION_LIMIT=50
|
||||
|
||||
# Allow <script> tags in page content
|
||||
# Note, if set to 'true' the page editor may still escape scripts.
|
||||
@@ -328,44 +262,9 @@ ALLOW_CONTENT_SCRIPTS=false
|
||||
# Contents of the robots.txt file can be overridden, making this option obsolete.
|
||||
ALLOW_ROBOTS=null
|
||||
|
||||
# Allow server-side fetches to be performed to potentially unknown
|
||||
# and user-provided locations. Primarily used in exports when loading
|
||||
# in externally referenced assets.
|
||||
# Can be 'true' or 'false'.
|
||||
ALLOW_UNTRUSTED_SERVER_FETCHING=false
|
||||
|
||||
# A list of hosts that BookStack can be iframed within.
|
||||
# Space separated if multiple. BookStack host domain is auto-inferred.
|
||||
# For Example: ALLOWED_IFRAME_HOSTS="https://example.com https://a.example.com"
|
||||
# Setting this option will also auto-adjust cookies to be SameSite=None.
|
||||
ALLOWED_IFRAME_HOSTS=null
|
||||
|
||||
# A list of sources/hostnames that can be loaded within iframes within BookStack.
|
||||
# Space separated if multiple. BookStack host domain is auto-inferred.
|
||||
# Can be set to a lone "*" to allow all sources for iframe content (Not advised).
|
||||
# Defaults to a set of common services.
|
||||
# Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
|
||||
ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com"
|
||||
|
||||
# The default and maximum item-counts for listing API requests.
|
||||
API_DEFAULT_ITEM_COUNT=100
|
||||
API_MAX_ITEM_COUNT=500
|
||||
|
||||
# The number of API requests that can be made per minute by a single user.
|
||||
API_REQUESTS_PER_MIN=180
|
||||
|
||||
# Enable the logging of failed email+password logins with the given message.
|
||||
# The default log channel below uses the php 'error_log' function which commonly
|
||||
# results in messages being output to the webserver error logs.
|
||||
# The message can contain a %u parameter which will be replaced with the login
|
||||
# user identifier (Username or email).
|
||||
LOG_FAILED_LOGIN_MESSAGE=false
|
||||
LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
|
||||
|
||||
# Alter the precision of IP addresses stored by BookStack.
|
||||
# Should be a number between 0 and 4, where 4 retains the full IP address
|
||||
# and 0 completely hides the IP address. As an example, a value of 2 for the
|
||||
# IP address '146.191.42.4' would result in '146.191.x.x' being logged.
|
||||
# For the IPv6 address '2001:db8:85a3:8d3:1319:8a2e:370:7348' this would result as:
|
||||
# '2001:db8:85a3:8d3:x:x:x:x'
|
||||
IP_ADDRESS_PRECISION=4
|
||||
API_REQUESTS_PER_MIN=180
|
||||
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,4 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [ssddanbrown]
|
||||
ko_fi: ssddanbrown
|
||||
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.
|
||||
70
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
70
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,70 +0,0 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve or fix things
|
||||
labels: [":bug: Bug"]
|
||||
body:
|
||||
- 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.
|
||||
placeholder: (eg. v21.08.5)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: phpversion
|
||||
attributes:
|
||||
label: PHP Version
|
||||
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that relevant to the issue.
|
||||
placeholder: (eg. 7.4)
|
||||
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. Ubuntu 20.04 VPS, installed using official installation script)
|
||||
validations:
|
||||
required: true
|
||||
13
.github/ISSUE_TEMPLATE/config.yml
vendored
13
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,13 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord Chat Support
|
||||
url: https://discord.gg/ztkBqR2
|
||||
about: Realtime support & chat with the BookStack community and the team.
|
||||
|
||||
- name: Debugging & Common Issues
|
||||
url: https://www.bookstackapp.com/docs/admin/debugging/
|
||||
about: Find details on how to debug issues and view common issues with their resolutions.
|
||||
|
||||
- name: Official Support Plans
|
||||
url: https://www.bookstackapp.com/support/
|
||||
about: View our official support plans that offer assured support for business.
|
||||
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 fundemental benefit/goal of your request.
|
||||
options:
|
||||
- label: I have searched for existing issues and none cover my fundemental request
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: existing_usage
|
||||
attributes:
|
||||
label: How long have you been using BookStack?
|
||||
options:
|
||||
- Not using yet, just scoping
|
||||
- 0 to 6 months
|
||||
- 6 months to 1 year
|
||||
- 1 to 5 years
|
||||
- Over 5 years
|
||||
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.__*
|
||||
62
.github/ISSUE_TEMPLATE/support_request.yml
vendored
62
.github/ISSUE_TEMPLATE/support_request.yml
vendored
@@ -1,62 +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. v21.08.5)
|
||||
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
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: phpversion
|
||||
attributes:
|
||||
label: PHP Version
|
||||
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that most relevant to the issue.
|
||||
placeholder: (eg. 7.4)
|
||||
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. Ubuntu 20.04 VPS, installed using official installation script)
|
||||
validations:
|
||||
required: true
|
||||
32
.github/SECURITY.md
vendored
32
.github/SECURITY.md
vendored
@@ -1,32 +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 use one of the below
|
||||
methods to report the vulnerability:
|
||||
|
||||
- Directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown).
|
||||
- You will need to login to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
|
||||
- Alternatively you can send a DM via Twitter to [@ssddanbrown](https://twitter.com/ssddanbrown).
|
||||
- [Disclose via huntr.dev](https://huntr.dev/bounties/disclose)
|
||||
- Bounties may be available to you through this platform.
|
||||
- Be sure to use `https://github.com/BookStackApp/BookStack` as the repository URL.
|
||||
|
||||
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!
|
||||
231
.github/translators.txt
vendored
231
.github/translators.txt
vendored
@@ -49,14 +49,6 @@ Name :: Languages
|
||||
@jzoy :: Simplified Chinese
|
||||
@ististudio :: Korean
|
||||
@leomartinez :: Spanish Argentina
|
||||
@geins :: German
|
||||
@Ereza :: Catalan
|
||||
@benediktvolke :: German
|
||||
@Baptistou :: French
|
||||
@arcoai :: Spanish
|
||||
@Jokuna :: Korean
|
||||
@smartshogu :: German; German Informal
|
||||
@samadha56 :: Persian
|
||||
cipi1965 :: Italian
|
||||
Mykola Ronik (Mantikor) :: Ukrainian
|
||||
furkanoyk :: Turkish
|
||||
@@ -69,7 +61,7 @@ Rodrigo Saczuk Niz (rodrigoniz) :: Portuguese, Brazilian
|
||||
aekramer :: Dutch
|
||||
JachuPL :: Polish
|
||||
milesteg :: Hungarian
|
||||
Beenbag :: German; German Informal
|
||||
Beenbag :: German
|
||||
Lett3rs :: Danish
|
||||
Julian (julian.henneberg) :: German; German Informal
|
||||
3GNWn :: Danish
|
||||
@@ -77,224 +69,5 @@ dbguichu :: Chinese Simplified
|
||||
Randy Kim (hyunjun) :: Korean
|
||||
Francesco M. Taurino (ftaurino) :: Italian
|
||||
DanielFrederiksen :: Danish
|
||||
Finn Wessel (19finnwessel6) :: German Informal; German
|
||||
Finn Wessel (19finnwessel6) :: German
|
||||
Gustav Kånåhols (Kurbitz) :: Swedish
|
||||
Vuong Trung Hieu (fpooon) :: Vietnamese
|
||||
Emil Petersen (emoyly) :: Danish
|
||||
mrjaboozy :: Slovenian
|
||||
Statium :: Russian
|
||||
Mikkel Struntze (MStruntze) :: Danish
|
||||
kostefun :: Russian
|
||||
Tuyen.NG (tuyendev) :: Vietnamese
|
||||
Ghost_chu (dbguichu) :: Chinese Simplified
|
||||
Ziipen :: Danish
|
||||
Samuel Schwarz (Guiph7quan) :: Czech
|
||||
Aleph (toishoki) :: Turkish
|
||||
Julio Alberto García (Yllelder) :: Spanish
|
||||
Rafael (raribeir) :: Portuguese, Brazilian
|
||||
Hiroyuki Odake (dakesan) :: Japanese
|
||||
Alex Lee (qianmengnet) :: Chinese Simplified
|
||||
swinn37 :: French
|
||||
Hasan Özbey (the-turk) :: Turkish
|
||||
rcy :: Swedish
|
||||
Ali Yasir Yılmaz (ayyilmaz) :: Turkish
|
||||
scureza :: Italian
|
||||
Biepa :: German Informal; German
|
||||
syecu :: Chinese Simplified
|
||||
Lap1t0r :: French
|
||||
Thinkverse (thinkverse) :: Swedish
|
||||
alef (toishoki) :: Turkish
|
||||
Robbert Feunekes (Muukuro) :: Dutch
|
||||
seohyeon.joo :: Korean
|
||||
Orenda (OREDNA) :: Bulgarian
|
||||
Marek Pavelka (marapavelka) :: Czech
|
||||
Venkinovec :: Czech
|
||||
Tommy Ku (tommyku) :: Chinese Traditional; Japanese
|
||||
Michał Bielejewski (bielej) :: Polish
|
||||
jozefrebjak :: Slovak
|
||||
Ikhwan Koo (Ikhwan.Koo) :: Korean
|
||||
Whay (remkovdhoef) :: Dutch
|
||||
jc7115 :: Chinese Traditional
|
||||
주서현 (seohyeon.joo) :: Korean
|
||||
ReadySystems :: Arabic
|
||||
HFinch :: German; German Informal
|
||||
brechtgijsens :: Dutch
|
||||
Lowkey (v587ygq) :: Chinese Simplified
|
||||
sdl-blue :: German Informal
|
||||
sqlik :: Polish
|
||||
Roy van Schaijk (royvanschaijk) :: Dutch
|
||||
Simsimpicpic :: French
|
||||
Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
|
||||
tatsuya.info :: Japanese
|
||||
fadiapp :: Arabic
|
||||
Jakub Bouček (jakubboucek) :: Czech
|
||||
Marco (cdrfun) :: German; German Informal
|
||||
10935336 :: Chinese Simplified
|
||||
孟繁阳 (FanyangMeng) :: Chinese Simplified
|
||||
Andrej Močan (andrejm) :: Slovenian
|
||||
gilane9_ :: Arabic
|
||||
Raed alnahdi (raednahdi) :: Arabic
|
||||
Xiphoseer :: German
|
||||
MerlinSVK (merlinsvk) :: Slovak
|
||||
Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
|
||||
MatthieuParis :: French
|
||||
Douradinho :: Portuguese, Brazilian; Portuguese
|
||||
Gaku Yaguchi (tama11) :: Japanese
|
||||
johnroyer :: Chinese Traditional
|
||||
jackaaa :: Chinese Traditional
|
||||
Irfan Hukama Arsyad (IrfanArsyad) :: Indonesian
|
||||
Jeff Huang (s8321414) :: Chinese Traditional
|
||||
Luís Tiago Favas (starkyller) :: Portuguese
|
||||
semirte :: Bosnian
|
||||
aarchijs :: Latvian
|
||||
Martins Pilsetnieks (pilsetnieks) :: Latvian
|
||||
Yonatan Magier (yonatanmgr) :: Hebrew
|
||||
FastHogi :: German Informal; German
|
||||
Ole Anders (Swoy) :: Norwegian Bokmal
|
||||
Atlochowski (atlochowski) :: Polish
|
||||
Simon (DefaultSimon) :: Slovenian
|
||||
Reinis Mednis (Mednis) :: Latvian
|
||||
toisho (toishoki) :: Turkish
|
||||
nikservik :: Ukrainian; Russian; Polish
|
||||
HenrijsS :: Latvian
|
||||
Pascal R-B (pborgner) :: German
|
||||
Boris (Ginfred) :: Russian
|
||||
Jonas Anker Rasmussen (jonasanker) :: Danish
|
||||
Gerwin de Keijzer (gdekeijzer) :: Dutch; German Informal; German
|
||||
kometchtech :: Japanese
|
||||
Auri (Atalonica) :: Catalan
|
||||
Francesco Franchina (ffranchina) :: Italian
|
||||
Aimrane Kds (aimrane.kds) :: Arabic
|
||||
whenwesober :: Indonesian
|
||||
Rem (remkovdhoef) :: Dutch
|
||||
syn7ax69 :: Bulgarian; Turkish; German
|
||||
Blaade :: French
|
||||
Behzad HosseinPoor (behzad.hp) :: Persian
|
||||
Ole Aldric (Swoy) :: Norwegian Bokmal
|
||||
fharis arabia (raednahdi) :: Arabic
|
||||
Alexander Predl (Harveyhase68) :: German
|
||||
Rem (Rem9000) :: Dutch
|
||||
Michał Stelmach (stelmach-web) :: Polish
|
||||
arniom :: French
|
||||
REMOVED_USER :: ; Dutch; Turkish
|
||||
林祖年 (contagion) :: Chinese Traditional
|
||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||
Nathanaël (nathanaelhoun) :: French
|
||||
A Ibnu Hibban (abd.ibnuhibban) :: Indonesian
|
||||
Frost-ZX :: Chinese Simplified
|
||||
Kuzma Simonov (ovmach) :: Russian
|
||||
Vojtěch Krystek (acantophis) :: Czech
|
||||
Michał Lipok (mLipok) :: Polish
|
||||
Nicolas Pawlak (Mikolajek) :: French; Polish; German
|
||||
Thomas Hansen (thomasdk81) :: Danish
|
||||
Hl2run :: Slovak
|
||||
Ngo Tri Hoai (trihoai) :: Vietnamese
|
||||
Atalonica :: Catalan
|
||||
慕容潭谈 (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
|
||||
Ji-Hyeon Gim (PotatoGim) :: Korean
|
||||
Mihai Ochian (soulstorm19) :: Romanian
|
||||
HeartCore :: German Informal; German
|
||||
simon.pct :: French
|
||||
okaeiz :: Persian
|
||||
Naoto Ishikawa (na3shkw) :: Japanese
|
||||
sdhadi :: Persian
|
||||
DerLinkman (derlinkman) :: German; German Informal
|
||||
TurnArabic :: Arabic
|
||||
Martin Sebek (sebekmartin) :: Czech
|
||||
Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
|
||||
digilady :: Greek
|
||||
Linus (LinusOP) :: Swedish
|
||||
Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian
|
||||
RandomUser0815 :: German
|
||||
Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
|
||||
구인회 (laskdjlaskdj12) :: Korean
|
||||
LiZerui (CNLiZerui) :: Chinese Traditional
|
||||
Fabrice Boyer (FabriceBoyer) :: French
|
||||
mikael (bitcanon) :: Swedish
|
||||
Matthias Mai (schnapsidee) :: German
|
||||
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
|
||||
Jan Mitrof (jan.kachlik) :: Czech
|
||||
edwardsmirnov :: Russian
|
||||
Mr_OSS117 :: French
|
||||
shotu :: French
|
||||
Cesar_Lopez_Aguillon :: Spanish
|
||||
bdewoop :: German
|
||||
dina davoudi (dina.davoudi) :: Persian
|
||||
|
||||
34
.github/workflows/analyse-php.yml
vendored
34
.github/workflows/analyse-php.yml
vendored
@@ -1,34 +0,0 @@
|
||||
name: analyse-php
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
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@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-8.1
|
||||
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
|
||||
19
.github/workflows/lint-php.yml
vendored
19
.github/workflows/lint-php.yml
vendored
@@ -1,19 +0,0 @@
|
||||
name: lint-php
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
tools: phpcs
|
||||
|
||||
- name: Run formatting check
|
||||
run: composer lint
|
||||
@@ -1,47 +1,44 @@
|
||||
name: test-php
|
||||
name: phpunit
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- release
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- '*/*'
|
||||
- '!l10n_master'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4', '8.0', '8.1', '8.2']
|
||||
php: [7.2, 7.3]
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Start Database
|
||||
run: |
|
||||
sudo systemctl start mysql
|
||||
|
||||
- name: Setup Database
|
||||
run: |
|
||||
mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
|
||||
mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';"
|
||||
mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED BY 'bookstack-test';"
|
||||
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
|
||||
mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
|
||||
|
||||
- name: Install composer dependencies
|
||||
- name: Install composer dependencies & Test
|
||||
run: composer install --prefer-dist --no-interaction --ansi
|
||||
|
||||
- name: Migrate and seed the database
|
||||
@@ -49,5 +46,5 @@ jobs:
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
||||
php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
|
||||
|
||||
- name: Run PHP tests
|
||||
- name: phpunit
|
||||
run: php${{ matrix.php }} ./vendor/bin/phpunit
|
||||
57
.github/workflows/test-migrations.yml
vendored
57
.github/workflows/test-migrations.yml
vendored
@@ -1,57 +0,0 @@
|
||||
name: test-migrations
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4', '8.0', '8.1', '8.2']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Start MySQL
|
||||
run: |
|
||||
sudo systemctl start mysql
|
||||
|
||||
- name: Create database & user
|
||||
run: |
|
||||
mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
|
||||
mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';"
|
||||
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
|
||||
mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --ansi
|
||||
|
||||
- name: Start migration test
|
||||
run: |
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
||||
|
||||
- name: Start migration:rollback test
|
||||
run: |
|
||||
php${{ matrix.php }} artisan migrate:rollback --force -n --database=mysql_testing
|
||||
|
||||
- name: Start migration rerun test
|
||||
run: |
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -5,10 +5,10 @@ Homestead.yaml
|
||||
.idea
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/public/dist
|
||||
/public/dist/*.map
|
||||
/public/plugins
|
||||
/public/css
|
||||
/public/js
|
||||
/public/css/*.map
|
||||
/public/js/*.map
|
||||
/public/bower
|
||||
/public/build/
|
||||
/storage/images
|
||||
@@ -23,5 +23,4 @@ nbproject
|
||||
.settings/
|
||||
webpack-stats.json
|
||||
.phpunit.result.cache
|
||||
.DS_Store
|
||||
phpstan.neon
|
||||
.DS_Store
|
||||
3
LICENSE
3
LICENSE
@@ -1,6 +1,7 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-2022, Dan Brown and the BookStack Project contributors.
|
||||
Copyright (c) 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
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -3,66 +3,58 @@
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\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 string $key
|
||||
* @property User $user
|
||||
* @property Entity $entity
|
||||
* @property string $detail
|
||||
* @property string $extra
|
||||
* @property string $entity_type
|
||||
* @property int $entity_id
|
||||
* @property int $user_id
|
||||
* @property int $entity_id
|
||||
* @property int $user_id
|
||||
* @property int $book_id
|
||||
*/
|
||||
class Activity extends Model
|
||||
{
|
||||
|
||||
/**
|
||||
* Get the entity for this activity.
|
||||
*/
|
||||
public function entity(): MorphTo
|
||||
public function entity()
|
||||
{
|
||||
if ($this->entity_type === '') {
|
||||
$this->entity_type = null;
|
||||
}
|
||||
|
||||
return $this->morphTo('entity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user this activity relates to.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns text from the language files, Looks up by using the activity key.
|
||||
* Returns text from the language files, Looks up by using the
|
||||
* activity key.
|
||||
*/
|
||||
public function getText(): string
|
||||
public function getText()
|
||||
{
|
||||
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_',
|
||||
]);
|
||||
return trans('activities.' . $this->key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if another Activity matches the general information of another.
|
||||
* @param $activityB
|
||||
* @return bool
|
||||
*/
|
||||
public function isSimilarTo(self $activityB): bool
|
||||
public function isSimilarTo($activityB)
|
||||
{
|
||||
return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
|
||||
return [$this->key, $this->entity_type, $this->entity_id] === [$activityB->key, $activityB->entity_type, $activityB->entity_id];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ActivityLogger
|
||||
{
|
||||
/**
|
||||
* Add a generic activity event to the database.
|
||||
*
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
public function add(string $type, $detail = '')
|
||||
{
|
||||
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
|
||||
|
||||
$activity = $this->newActivityForUser($type);
|
||||
$activity->detail = $detailToStore;
|
||||
|
||||
if ($detail instanceof Entity) {
|
||||
$activity->entity_id = $detail->id;
|
||||
$activity->entity_type = $detail->getMorphClass();
|
||||
}
|
||||
|
||||
$activity->save();
|
||||
|
||||
$this->setNotification($type);
|
||||
$this->dispatchWebhooks($type, $detail);
|
||||
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)
|
||||
{
|
||||
$entity->activity()->update([
|
||||
'detail' => $entity->name,
|
||||
'entity_id' => null,
|
||||
'entity_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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
protected function dispatchWebhooks(string $type, $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)
|
||||
{
|
||||
$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,118 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class ActivityQueries
|
||||
{
|
||||
protected PermissionApplicator $permissions;
|
||||
|
||||
protected array $fieldsForLists = ['id', 'type', 'detail', 'activities.entity_type', 'activities.entity_id', 'user_id', 'created_at'];
|
||||
|
||||
public function __construct(PermissionApplicator $permissions)
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest activity.
|
||||
*/
|
||||
public function latest(int $count = 20, int $page = 0): array
|
||||
{
|
||||
$query = Activity::query()->select($this->fieldsForLists);
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery($query, 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->whereNotNull('activities.entity_id')
|
||||
->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 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('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 the latest activity for a user, Filtering out similar items.
|
||||
*/
|
||||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
$query = Activity::query()->select($this->fieldsForLists);
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery($query, 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->where('user_id', '=', $user->id)
|
||||
->whereNotNull('activities.entity_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;
|
||||
}
|
||||
}
|
||||
186
app/Actions/ActivityService.php
Normal file
186
app/Actions/ActivityService.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\Book;
|
||||
use BookStack\Entities\Entity;
|
||||
|
||||
class ActivityService
|
||||
{
|
||||
protected $activity;
|
||||
protected $user;
|
||||
protected $permissionService;
|
||||
|
||||
/**
|
||||
* ActivityService constructor.
|
||||
* @param Activity $activity
|
||||
* @param PermissionService $permissionService
|
||||
*/
|
||||
public function __construct(Activity $activity, PermissionService $permissionService)
|
||||
{
|
||||
$this->activity = $activity;
|
||||
$this->permissionService = $permissionService;
|
||||
$this->user = user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add activity data to database.
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @param string $activityKey
|
||||
* @param int $bookId
|
||||
*/
|
||||
public function add(Entity $entity, string $activityKey, int $bookId = null)
|
||||
{
|
||||
$activity = $this->newActivityForUser($activityKey, $bookId);
|
||||
$entity->activity()->save($activity);
|
||||
$this->setNotification($activityKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a activity history with a message, without binding to a entity.
|
||||
* @param string $activityKey
|
||||
* @param string $message
|
||||
* @param int $bookId
|
||||
*/
|
||||
public function addMessage(string $activityKey, string $message, int $bookId = null)
|
||||
{
|
||||
$this->newActivityForUser($activityKey, $bookId)->forceFill([
|
||||
'extra' => $message
|
||||
])->save();
|
||||
|
||||
$this->setNotification($activityKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new activity instance for the current user.
|
||||
* @param string $key
|
||||
* @param int|null $bookId
|
||||
* @return Activity
|
||||
*/
|
||||
protected function newActivityForUser(string $key, int $bookId = null)
|
||||
{
|
||||
return $this->activity->newInstance()->forceFill([
|
||||
'key' => strtolower($key),
|
||||
'user_id' => $this->user->id,
|
||||
'book_id' => $bookId ?? 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @return mixed
|
||||
*/
|
||||
public function removeEntity(Entity $entity)
|
||||
{
|
||||
// TODO - Rewrite to db query.
|
||||
$activities = $entity->activity;
|
||||
foreach ($activities as $activity) {
|
||||
$activity->extra = $entity->name;
|
||||
$activity->entity_id = 0;
|
||||
$activity->entity_type = null;
|
||||
$activity->save();
|
||||
}
|
||||
return $activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest activity.
|
||||
* @param int $count
|
||||
* @param int $page
|
||||
* @return array
|
||||
*/
|
||||
public function latest($count = 20, $page = 0)
|
||||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations($this->activity, '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.
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @param int $count
|
||||
* @param int $page
|
||||
* @return array
|
||||
*/
|
||||
public function entityActivity($entity, $count = 20, $page = 1)
|
||||
{
|
||||
if ($entity->isA('book')) {
|
||||
$query = $this->activity->where('book_id', '=', $entity->id);
|
||||
} else {
|
||||
$query = $this->activity->where('entity_type', '=', $entity->getMorphClass())
|
||||
->where('entity_id', '=', $entity->id);
|
||||
}
|
||||
|
||||
$activity = $this->permissionService
|
||||
->filterRestrictedEntityRelations($query, 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->with(['entity', 'user.avatar'])
|
||||
->skip($count * ($page - 1))
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
return $this->filterSimilar($activity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest activity for a user, Filtering out similar
|
||||
* items.
|
||||
* @param $user
|
||||
* @param int $count
|
||||
* @param int $page
|
||||
* @return array
|
||||
*/
|
||||
public function userActivity($user, $count = 20, $page = 0)
|
||||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations($this->activity, '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($activities)
|
||||
{
|
||||
$newActivity = [];
|
||||
$previousItem = false;
|
||||
foreach ($activities as $activityItem) {
|
||||
if ($previousItem === false) {
|
||||
$previousItem = $activityItem;
|
||||
$newActivity[] = $activityItem;
|
||||
continue;
|
||||
}
|
||||
if (!$activityItem->isSimilarTo($previousItem)) {
|
||||
$newActivity[] = $activityItem;
|
||||
}
|
||||
$previousItem = $activityItem;
|
||||
}
|
||||
return $newActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flashes a notification message to the session if an appropriate message is available.
|
||||
* @param $activityKey
|
||||
*/
|
||||
protected function setNotification($activityKey)
|
||||
{
|
||||
$notificationTextKey = 'activities.' . $activityKey . '_notification';
|
||||
if (trans()->has($notificationTextKey)) {
|
||||
$message = trans($notificationTextKey);
|
||||
session()->flash('success', $message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
class ActivityType
|
||||
{
|
||||
const PAGE_CREATE = 'page_create';
|
||||
const PAGE_UPDATE = 'page_update';
|
||||
const PAGE_DELETE = 'page_delete';
|
||||
const PAGE_RESTORE = 'page_restore';
|
||||
const PAGE_MOVE = 'page_move';
|
||||
|
||||
const CHAPTER_CREATE = 'chapter_create';
|
||||
const CHAPTER_UPDATE = 'chapter_update';
|
||||
const CHAPTER_DELETE = 'chapter_delete';
|
||||
const CHAPTER_MOVE = 'chapter_move';
|
||||
|
||||
const BOOK_CREATE = 'book_create';
|
||||
const BOOK_CREATE_FROM_CHAPTER = 'book_create_from_chapter';
|
||||
const BOOK_UPDATE = 'book_update';
|
||||
const BOOK_DELETE = 'book_delete';
|
||||
const BOOK_SORT = 'book_sort';
|
||||
|
||||
const BOOKSHELF_CREATE = 'bookshelf_create';
|
||||
const BOOKSHELF_CREATE_FROM_BOOK = 'bookshelf_create_from_book';
|
||||
const BOOKSHELF_UPDATE = 'bookshelf_update';
|
||||
const BOOKSHELF_DELETE = 'bookshelf_delete';
|
||||
|
||||
const COMMENTED_ON = 'commented_on';
|
||||
const PERMISSIONS_UPDATE = 'permissions_update';
|
||||
|
||||
const REVISION_RESTORE = 'revision_restore';
|
||||
const REVISION_DELETE = 'revision_delete';
|
||||
|
||||
const SETTINGS_UPDATE = 'settings_update';
|
||||
const MAINTENANCE_ACTION_RUN = 'maintenance_action_run';
|
||||
|
||||
const RECYCLE_BIN_EMPTY = 'recycle_bin_empty';
|
||||
const RECYCLE_BIN_RESTORE = 'recycle_bin_restore';
|
||||
const RECYCLE_BIN_DESTROY = 'recycle_bin_destroy';
|
||||
|
||||
const USER_CREATE = 'user_create';
|
||||
const USER_UPDATE = 'user_update';
|
||||
const USER_DELETE = 'user_delete';
|
||||
|
||||
const API_TOKEN_CREATE = 'api_token_create';
|
||||
const API_TOKEN_UPDATE = 'api_token_update';
|
||||
const API_TOKEN_DELETE = 'api_token_delete';
|
||||
|
||||
const ROLE_CREATE = 'role_create';
|
||||
const ROLE_UPDATE = 'role_update';
|
||||
const ROLE_DELETE = 'role_delete';
|
||||
|
||||
const AUTH_PASSWORD_RESET = 'auth_password_reset_request';
|
||||
const AUTH_PASSWORD_RESET_UPDATE = 'auth_password_reset_update';
|
||||
const AUTH_LOGIN = 'auth_login';
|
||||
const AUTH_REGISTER = 'auth_register';
|
||||
|
||||
const MFA_SETUP_METHOD = 'mfa_setup_method';
|
||||
const MFA_REMOVE_METHOD = 'mfa_remove_method';
|
||||
|
||||
const WEBHOOK_CREATE = 'webhook_create';
|
||||
const WEBHOOK_UPDATE = 'webhook_update';
|
||||
const WEBHOOK_DELETE = 'webhook_delete';
|
||||
|
||||
/**
|
||||
* Get all the possible values.
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return (new \ReflectionClass(static::class))->getConstants();
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,32 @@
|
||||
<?php
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
namespace BookStack\Actions;
|
||||
use BookStack\Ownable;
|
||||
|
||||
use BookStack\Model;
|
||||
use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
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
|
||||
class Comment extends Ownable
|
||||
{
|
||||
use HasFactory;
|
||||
use HasCreatorAndUpdater;
|
||||
|
||||
protected $fillable = ['text', 'parent_id'];
|
||||
protected $fillable = ['text', 'html', 'parent_id'];
|
||||
protected $appends = ['created', 'updated'];
|
||||
|
||||
/**
|
||||
* Get the entity that this comment belongs to.
|
||||
* Get the entity that this comment belongs to
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
*/
|
||||
public function entity(): MorphTo
|
||||
public function entity()
|
||||
{
|
||||
return $this->morphTo('entity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a comment has been updated since creation.
|
||||
* @return bool
|
||||
*/
|
||||
public function isUpdated(): bool
|
||||
public function isUpdated()
|
||||
{
|
||||
return $this->updated_at->timestamp > $this->created_at->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get created date as a relative diff.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getCreatedAttribute()
|
||||
@@ -50,7 +36,6 @@ class Comment extends Model
|
||||
|
||||
/**
|
||||
* Get updated date as a relative diff.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUpdatedAttribute()
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
<?php
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Activity as ActivityService;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use BookStack\Entities\Entity;
|
||||
|
||||
/**
|
||||
* Class CommentRepo.
|
||||
* Class CommentRepo
|
||||
* @package BookStack\Repos
|
||||
*/
|
||||
class CommentRepo
|
||||
{
|
||||
|
||||
/**
|
||||
* @var Comment
|
||||
* @var \BookStack\Actions\Comment $comment
|
||||
*/
|
||||
protected $comment;
|
||||
|
||||
/**
|
||||
* CommentRepo constructor.
|
||||
* @param \BookStack\Actions\Comment $comment
|
||||
*/
|
||||
public function __construct(Comment $comment)
|
||||
{
|
||||
$this->comment = $comment;
|
||||
@@ -23,76 +25,65 @@ class CommentRepo
|
||||
|
||||
/**
|
||||
* Get a comment by ID.
|
||||
* @param $id
|
||||
* @return \BookStack\Actions\Comment|\Illuminate\Database\Eloquent\Model
|
||||
*/
|
||||
public function getById(int $id): Comment
|
||||
public function getById($id)
|
||||
{
|
||||
return $this->comment->newQuery()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment on an entity.
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @param array $data
|
||||
* @return \BookStack\Actions\Comment
|
||||
*/
|
||||
public function create(Entity $entity, string $text, ?int $parent_id): Comment
|
||||
public function create(Entity $entity, $data = [])
|
||||
{
|
||||
$userId = user()->id;
|
||||
$comment = $this->comment->newInstance();
|
||||
|
||||
$comment->text = $text;
|
||||
$comment->html = $this->commentToHtml($text);
|
||||
$comment = $this->comment->newInstance($data);
|
||||
$comment->created_by = $userId;
|
||||
$comment->updated_by = $userId;
|
||||
$comment->local_id = $this->getNextLocalId($entity);
|
||||
$comment->parent_id = $parent_id;
|
||||
|
||||
$entity->comments()->save($comment);
|
||||
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing comment.
|
||||
* @param \BookStack\Actions\Comment $comment
|
||||
* @param array $input
|
||||
* @return mixed
|
||||
*/
|
||||
public function update(Comment $comment, string $text): Comment
|
||||
public function update($comment, $input)
|
||||
{
|
||||
$comment->updated_by = user()->id;
|
||||
$comment->text = $text;
|
||||
$comment->html = $this->commentToHtml($text);
|
||||
$comment->save();
|
||||
|
||||
$comment->update($input);
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment from the system.
|
||||
* @param \BookStack\Actions\Comment $comment
|
||||
* @return mixed
|
||||
*/
|
||||
public function delete(Comment $comment): void
|
||||
public function delete($comment)
|
||||
{
|
||||
$comment->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given comment Markdown 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);
|
||||
return $comment->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next local ID relative to the linked entity.
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @return int
|
||||
*/
|
||||
protected function getNextLocalId(Entity $entity): int
|
||||
protected function getNextLocalId(Entity $entity)
|
||||
{
|
||||
/** @var Comment $comment */
|
||||
$comment = $entity->comments(false)->orderBy('local_id', 'desc')->first();
|
||||
|
||||
return ($comment->local_id ?? 0) + 1;
|
||||
$comments = $entity->comments(false)->orderBy('local_id', 'desc')->first();
|
||||
if ($comments === null) {
|
||||
return 1;
|
||||
}
|
||||
return $comments->local_id + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DispatchWebhookJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
protected Webhook $webhook;
|
||||
protected string $event;
|
||||
protected User $initiator;
|
||||
protected int $initiatedTime;
|
||||
|
||||
/**
|
||||
* @var string|Loggable
|
||||
*/
|
||||
protected $detail;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Webhook $webhook, string $event, $detail)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
$this->event = $event;
|
||||
$this->detail = $detail;
|
||||
$this->initiator = user();
|
||||
$this->initiatedTime = time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime);
|
||||
$webhookData = $themeResponse ?? WebhookFormatter::getDefault($this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime)->format();
|
||||
$lastError = null;
|
||||
|
||||
try {
|
||||
$response = Http::asJson()
|
||||
->withOptions(['allow_redirects' => ['strict' => true]])
|
||||
->timeout($this->webhook->timeout)
|
||||
->post($this->webhook->endpoint, $webhookData);
|
||||
} catch (\Exception $exception) {
|
||||
$lastError = $exception->getMessage();
|
||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
|
||||
}
|
||||
|
||||
if (isset($response) && $response->failed()) {
|
||||
$lastError = "Response status from endpoint was {$response->status()}";
|
||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
|
||||
}
|
||||
|
||||
$this->webhook->last_called_at = now();
|
||||
if ($lastError) {
|
||||
$this->webhook->last_errored_at = now();
|
||||
$this->webhook->last_error = $lastError;
|
||||
}
|
||||
|
||||
$this->webhook->save();
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?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,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
class IpFormatter
|
||||
{
|
||||
protected string $ip;
|
||||
protected int $precision;
|
||||
|
||||
public function __construct(string $ip, int $precision)
|
||||
{
|
||||
$this->ip = trim($ip);
|
||||
$this->precision = max(0, min($precision, 4));
|
||||
}
|
||||
|
||||
public function format(): string
|
||||
{
|
||||
if (empty($this->ip) || $this->precision === 4) {
|
||||
return $this->ip;
|
||||
}
|
||||
|
||||
return $this->isIpv6() ? $this->maskIpv6() : $this->maskIpv4();
|
||||
}
|
||||
|
||||
protected function maskIpv4(): string
|
||||
{
|
||||
$exploded = $this->explodeAndExpandIp('.', 4);
|
||||
$maskGroupCount = min(4 - $this->precision, count($exploded));
|
||||
|
||||
for ($i = 0; $i < $maskGroupCount; $i++) {
|
||||
$exploded[3 - $i] = 'x';
|
||||
}
|
||||
|
||||
return implode('.', $exploded);
|
||||
}
|
||||
|
||||
protected function maskIpv6(): string
|
||||
{
|
||||
$exploded = $this->explodeAndExpandIp(':', 8);
|
||||
$maskGroupCount = min(8 - ($this->precision * 2), count($exploded));
|
||||
|
||||
for ($i = 0; $i < $maskGroupCount; $i++) {
|
||||
$exploded[7 - $i] = 'x';
|
||||
}
|
||||
|
||||
return implode(':', $exploded);
|
||||
}
|
||||
|
||||
protected function isIpv6(): bool
|
||||
{
|
||||
return strpos($this->ip, ':') !== false;
|
||||
}
|
||||
|
||||
protected function explodeAndExpandIp(string $separator, int $targetLength): array
|
||||
{
|
||||
$exploded = explode($separator, $this->ip);
|
||||
|
||||
while (count($exploded) < $targetLength) {
|
||||
$emptyIndex = array_search('', $exploded) ?: count($exploded) - 1;
|
||||
array_splice($exploded, $emptyIndex, 0, '0');
|
||||
}
|
||||
|
||||
$emptyIndex = array_search('', $exploded);
|
||||
if ($emptyIndex !== false) {
|
||||
$exploded[$emptyIndex] = '0';
|
||||
}
|
||||
|
||||
return $exploded;
|
||||
}
|
||||
|
||||
public static function fromCurrentRequest(): self
|
||||
{
|
||||
$ip = request()->ip() ?? '';
|
||||
|
||||
if (config('app.env') === 'demo') {
|
||||
$ip = '127.0.0.1';
|
||||
}
|
||||
|
||||
return new self($ip, config('app.ip_address_precision'));
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions\Queries;
|
||||
|
||||
use BookStack\Actions\Webhook;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Get all the webhooks in the system in a paginated format.
|
||||
*/
|
||||
class WebhooksAllPaginatedAndSorted
|
||||
{
|
||||
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
|
||||
{
|
||||
$query = Webhook::query()->select(['*'])
|
||||
->withCount(['trackedEvents'])
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder());
|
||||
|
||||
if ($listOptions->getSearch()) {
|
||||
$term = '%' . $listOptions->getSearch() . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('name', 'like', $term)
|
||||
->orWhere('endpoint', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate($count);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $value
|
||||
* @property int $order
|
||||
* Class Attribute
|
||||
* @package BookStack
|
||||
*/
|
||||
class Tag extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'value', 'order'];
|
||||
protected $hidden = ['id', 'entity_id', 'entity_type', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* Get the entity that this tag belongs to.
|
||||
* Get the entity that this tag belongs to
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
*/
|
||||
public function entity(): MorphTo
|
||||
public function entity()
|
||||
{
|
||||
return $this->morphTo('entity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a full URL to start a tag name search for this tag name.
|
||||
*/
|
||||
public function nameUrl(): string
|
||||
{
|
||||
return url('/search?term=%5B' . urlencode($this->name) . '%5D');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a full URL to start a tag name and value search for this tag's values.
|
||||
*/
|
||||
public function valueUrl(): string
|
||||
{
|
||||
return url('/search?term=%5B' . urlencode($this->name) . '%3D' . urlencode($this->value) . '%5D');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
class TagClassGenerator
|
||||
{
|
||||
protected array $tags;
|
||||
|
||||
/**
|
||||
* @param Tag[] $tags
|
||||
*/
|
||||
public function __construct(array $tags)
|
||||
{
|
||||
$this->tags = $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function generate(): array
|
||||
{
|
||||
$classes = [];
|
||||
|
||||
foreach ($this->tags as $tag) {
|
||||
$name = $this->normalizeTagClassString($tag->name);
|
||||
$value = $this->normalizeTagClassString($tag->value);
|
||||
$classes[] = 'tag-name-' . $name;
|
||||
if ($value) {
|
||||
$classes[] = 'tag-value-' . $value;
|
||||
$classes[] = 'tag-pair-' . $name . '-' . $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($classes);
|
||||
}
|
||||
|
||||
public function generateAsString(): string
|
||||
{
|
||||
return implode(' ', $this->generate());
|
||||
}
|
||||
|
||||
protected function normalizeTagClassString(string $value): string
|
||||
{
|
||||
$value = str_replace(' ', '', strtolower($value));
|
||||
$value = str_replace('-', '', strtolower($value));
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +1,94 @@
|
||||
<?php
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\Entity;
|
||||
|
||||
/**
|
||||
* Class TagRepo
|
||||
* @package BookStack\Repos
|
||||
*/
|
||||
class TagRepo
|
||||
{
|
||||
protected PermissionApplicator $permissions;
|
||||
|
||||
public function __construct(PermissionApplicator $permissions)
|
||||
protected $tag;
|
||||
protected $entity;
|
||||
protected $permissionService;
|
||||
|
||||
/**
|
||||
* TagRepo constructor.
|
||||
* @param \BookStack\Actions\Tag $attr
|
||||
* @param \BookStack\Entities\Entity $ent
|
||||
* @param \BookStack\Auth\Permissions\PermissionService $ps
|
||||
*/
|
||||
public function __construct(Tag $attr, Entity $ent, PermissionService $ps)
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
$this->tag = $attr;
|
||||
$this->entity = $ent;
|
||||
$this->permissionService = $ps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query against all tags in the system.
|
||||
* Get an entity instance of its particular type.
|
||||
* @param $entityType
|
||||
* @param $entityId
|
||||
* @param string $action
|
||||
* @return \Illuminate\Database\Eloquent\Model|null|static
|
||||
*/
|
||||
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
|
||||
public function getEntity($entityType, $entityId, $action = 'view')
|
||||
{
|
||||
$searchTerm = $listOptions->getSearch();
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'name' && $nameFilter) {
|
||||
$sort = 'value';
|
||||
$entityInstance = $this->entity->getEntityInstance($entityType);
|
||||
$searchQuery = $entityInstance->where('id', '=', $entityId)->with('tags');
|
||||
$searchQuery = $this->permissionService->enforceEntityRestrictions($entityType, $searchQuery, $action);
|
||||
return $searchQuery->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags for a particular entity.
|
||||
* @param string $entityType
|
||||
* @param int $entityId
|
||||
* @return mixed
|
||||
*/
|
||||
public function getForEntity($entityType, $entityId)
|
||||
{
|
||||
$entity = $this->getEntity($entityType, $entityId);
|
||||
if ($entity === null) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$entityTypeCol = DB::getTablePrefix() . 'tags.entity_type';
|
||||
$query = Tag::query()
|
||||
->select([
|
||||
'name',
|
||||
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
|
||||
DB::raw('COUNT(id) as usages'),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'page', 1, 0)) as page_count"),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'chapter', 1, 0)) as chapter_count"),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'book', 1, 0)) as book_count"),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'bookshelf', 1, 0)) as shelf_count"),
|
||||
])
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
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');
|
||||
return $entity->tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag name suggestions from scanning existing tag names.
|
||||
* If no search term is given the 50 most popular tag names are provided.
|
||||
* @param $searchTerm
|
||||
* @return array
|
||||
*/
|
||||
public function getNameSuggestions(string $searchTerm): Collection
|
||||
public function getNameSuggestions($searchTerm = false)
|
||||
{
|
||||
$query = Tag::query()
|
||||
->select('*', DB::raw('count(*) as count'))
|
||||
->groupBy('name');
|
||||
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name');
|
||||
|
||||
if ($searchTerm) {
|
||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'asc');
|
||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
|
||||
} else {
|
||||
$query = $query->orderBy('count', 'desc')->take(50);
|
||||
}
|
||||
|
||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
|
||||
return $query->pluck('name');
|
||||
$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.
|
||||
* @param $searchTerm
|
||||
* @param $tagName
|
||||
* @return array
|
||||
*/
|
||||
public function getValueSuggestions(string $searchTerm, string $tagName): Collection
|
||||
public function getValueSuggestions($searchTerm = false, $tagName = false)
|
||||
{
|
||||
$query = Tag::query()
|
||||
->select('*', DB::raw('count(*) as count'))
|
||||
->groupBy('value');
|
||||
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value');
|
||||
|
||||
if ($searchTerm) {
|
||||
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
|
||||
@@ -99,40 +96,45 @@ class TagRepo
|
||||
$query = $query->orderBy('count', 'desc')->take(50);
|
||||
}
|
||||
|
||||
if ($tagName) {
|
||||
if ($tagName !== false) {
|
||||
$query = $query->where('name', '=', $tagName);
|
||||
}
|
||||
|
||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
|
||||
return $query->pluck('value');
|
||||
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||
return $query->get(['value'])->pluck('value');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an array of tags to an entity.
|
||||
* Save an array of tags to an entity
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @param array $tags
|
||||
* @return array|\Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function saveTagsToEntity(Entity $entity, array $tags = []): iterable
|
||||
public function saveTagsToEntity(Entity $entity, $tags = [])
|
||||
{
|
||||
$entity->tags()->delete();
|
||||
|
||||
$newTags = collect($tags)->filter(function ($tag) {
|
||||
return boolval(trim($tag['name']));
|
||||
})->map(function ($tag) {
|
||||
return $this->newInstanceFromInput($tag);
|
||||
})->all();
|
||||
$newTags = [];
|
||||
foreach ($tags as $tag) {
|
||||
if (trim($tag['name']) === '') {
|
||||
continue;
|
||||
}
|
||||
$newTags[] = $this->newInstanceFromInput($tag);
|
||||
}
|
||||
|
||||
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.
|
||||
* @param $input
|
||||
* @return \BookStack\Actions\Tag
|
||||
*/
|
||||
protected function newInstanceFromInput(array $input): Tag
|
||||
protected function newInstanceFromInput($input)
|
||||
{
|
||||
return new Tag([
|
||||
'name' => trim($input['name']),
|
||||
'value' => trim($input['value'] ?? ''),
|
||||
]);
|
||||
$name = trim($input['name']);
|
||||
$value = isset($input['value']) ? trim($input['value']) : '';
|
||||
// Any other modification or cleanup required can go here
|
||||
$values = ['name' => $name, 'value' => $value];
|
||||
return $this->tag->newInstance($values);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,18 @@
|
||||
<?php
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Interfaces\Viewable;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* Class View
|
||||
* Views are stored per-item per-person within the database.
|
||||
* They can be used to find popular items or recently viewed items
|
||||
* at a per-person level. They do not record every view instance as an
|
||||
* activity. Only the latest and original view times could be recognised.
|
||||
*
|
||||
* @property int $views
|
||||
* @property int $user_id
|
||||
*/
|
||||
class View extends Model
|
||||
{
|
||||
|
||||
protected $fillable = ['user_id', 'views'];
|
||||
|
||||
/**
|
||||
* Get all owning viewable models.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
*/
|
||||
public function viewable(): MorphTo
|
||||
public function viewable()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the current user's view count for the given viewable model.
|
||||
*/
|
||||
public static function incrementFor(Viewable $viewable): int
|
||||
{
|
||||
$user = user();
|
||||
if (is_null($user) || $user->isDefault()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** @var View $view */
|
||||
$view = $viewable->views()->firstOrNew([
|
||||
'user_id' => $user->id,
|
||||
], ['views' => 0]);
|
||||
|
||||
$view->forceFill(['views' => $view->views + 1])->save();
|
||||
|
||||
return $view->views;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all views from the system.
|
||||
*/
|
||||
public static function clearAll()
|
||||
{
|
||||
static::query()->truncate();
|
||||
}
|
||||
}
|
||||
|
||||
114
app/Actions/ViewService.php
Normal file
114
app/Actions/ViewService.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\Book;
|
||||
use BookStack\Entities\Entity;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use DB;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ViewService
|
||||
{
|
||||
protected $view;
|
||||
protected $permissionService;
|
||||
protected $entityProvider;
|
||||
|
||||
/**
|
||||
* ViewService constructor.
|
||||
* @param View $view
|
||||
* @param PermissionService $permissionService
|
||||
* @param EntityProvider $entityProvider
|
||||
*/
|
||||
public function __construct(View $view, PermissionService $permissionService, EntityProvider $entityProvider)
|
||||
{
|
||||
$this->view = $view;
|
||||
$this->permissionService = $permissionService;
|
||||
$this->entityProvider = $entityProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a view to the given entity.
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @return int
|
||||
*/
|
||||
public function add(Entity $entity)
|
||||
{
|
||||
$user = user();
|
||||
if ($user === null || $user->isDefault()) {
|
||||
return 0;
|
||||
}
|
||||
$view = $entity->views()->where('user_id', '=', $user->id)->first();
|
||||
// Add view if model exists
|
||||
if ($view) {
|
||||
$view->increment('views');
|
||||
return $view->views;
|
||||
}
|
||||
|
||||
// Otherwise create new view count
|
||||
$entity->views()->save($this->view->newInstance([
|
||||
'user_id' => $user->id,
|
||||
'views' => 1
|
||||
]));
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entities with the most views.
|
||||
* @param int $count
|
||||
* @param int $page
|
||||
* @param string|array $filterModels
|
||||
* @param string $action - used for permission checking
|
||||
* @return Collection
|
||||
*/
|
||||
public function getPopular(int $count = 10, int $page = 0, array $filterModels = null, string $action = 'view')
|
||||
{
|
||||
$skipCount = $count * $page;
|
||||
$query = $this->permissionService
|
||||
->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type', $action)
|
||||
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
|
||||
->groupBy('viewable_id', 'viewable_type')
|
||||
->orderBy('view_count', 'desc');
|
||||
|
||||
if ($filterModels) {
|
||||
$query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels));
|
||||
}
|
||||
|
||||
return $query->with('viewable')->skip($skipCount)->take($count)->get()->pluck('viewable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recently viewed entities for the current user.
|
||||
* @param int $count
|
||||
* @param int $page
|
||||
* @param Entity|bool $filterModel
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUserRecentlyViewed($count = 10, $page = 0, $filterModel = false)
|
||||
{
|
||||
$user = user();
|
||||
if ($user === null || $user->isDefault()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$query = $this->permissionService
|
||||
->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type');
|
||||
|
||||
if ($filterModel) {
|
||||
$query = $query->where('viewable_type', '=', $filterModel->getMorphClass());
|
||||
}
|
||||
$query = $query->where('user_id', '=', $user->id);
|
||||
|
||||
$viewables = $query->with('viewable')->orderBy('updated_at', 'desc')
|
||||
->skip($count * $page)->take($count)->get()->pluck('viewable');
|
||||
return $viewables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all view counts by deleting all views.
|
||||
*/
|
||||
public function resetAll()
|
||||
{
|
||||
$this->view->truncate();
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Interfaces\Loggable;
|
||||
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,124 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class WebhookFormatter
|
||||
{
|
||||
protected Webhook $webhook;
|
||||
protected string $event;
|
||||
protected User $initiator;
|
||||
protected int $initiatedTime;
|
||||
|
||||
/**
|
||||
* @var string|Loggable
|
||||
*/
|
||||
protected $detail;
|
||||
|
||||
/**
|
||||
* @var array{condition: callable(string, Model):bool, format: callable(Model):void}[]
|
||||
*/
|
||||
protected $modelFormatters = [];
|
||||
|
||||
public function __construct(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
$this->event = $event;
|
||||
$this->initiator = $initiator;
|
||||
$this->initiatedTime = $initiatedTime;
|
||||
$this->detail = is_object($detail) ? clone $detail : $detail;
|
||||
}
|
||||
|
||||
public function format(): array
|
||||
{
|
||||
$data = [
|
||||
'event' => $this->event,
|
||||
'text' => $this->formatText(),
|
||||
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
|
||||
'triggered_by' => $this->initiator->attributesToArray(),
|
||||
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
|
||||
'webhook_id' => $this->webhook->id,
|
||||
'webhook_name' => $this->webhook->name,
|
||||
];
|
||||
|
||||
if (method_exists($this->detail, 'getUrl')) {
|
||||
$data['url'] = $this->detail->getUrl();
|
||||
}
|
||||
|
||||
if ($this->detail instanceof Model) {
|
||||
$data['related_item'] = $this->formatModel();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(string, Model):bool $condition
|
||||
* @param callable(Model):void $format
|
||||
*/
|
||||
public function addModelFormatter(callable $condition, callable $format): void
|
||||
{
|
||||
$this->modelFormatters[] = [
|
||||
'condition' => $condition,
|
||||
'format' => $format,
|
||||
];
|
||||
}
|
||||
|
||||
public function addDefaultModelFormatters(): void
|
||||
{
|
||||
// Load entity owner, creator, updater details
|
||||
$this->addModelFormatter(
|
||||
fn ($event, $model) => ($model instanceof Entity),
|
||||
fn ($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy'])
|
||||
);
|
||||
|
||||
// Load revision detail for page update and create events
|
||||
$this->addModelFormatter(
|
||||
fn ($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)),
|
||||
fn ($model) => $model->load('currentRevision')
|
||||
);
|
||||
}
|
||||
|
||||
protected function formatModel(): array
|
||||
{
|
||||
/** @var Model $model */
|
||||
$model = $this->detail;
|
||||
$model->unsetRelations();
|
||||
|
||||
foreach ($this->modelFormatters as $formatter) {
|
||||
if ($formatter['condition']($this->event, $model)) {
|
||||
$formatter['format']($model);
|
||||
}
|
||||
}
|
||||
|
||||
return $model->toArray();
|
||||
}
|
||||
|
||||
protected function formatText(): string
|
||||
{
|
||||
$textParts = [
|
||||
$this->initiator->name,
|
||||
trans('activities.' . $this->event),
|
||||
];
|
||||
|
||||
if ($this->detail instanceof Entity) {
|
||||
$textParts[] = '"' . $this->detail->name . '"';
|
||||
}
|
||||
|
||||
return implode(' ', $textParts);
|
||||
}
|
||||
|
||||
public static function getDefault(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime): self
|
||||
{
|
||||
$instance = new self($event, $webhook, $detail, $initiator, $initiatedTime);
|
||||
$instance->addDefaultModelFormatters();
|
||||
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
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,52 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Api;
|
||||
<?php namespace BookStack\Api;
|
||||
|
||||
use BookStack\Http\Controllers\Api\ApiController;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use ReflectionClass;
|
||||
use ReflectionException;
|
||||
use ReflectionMethod;
|
||||
|
||||
class ApiDocsGenerator
|
||||
{
|
||||
|
||||
protected $reflectionClasses = [];
|
||||
protected $controllerClasses = [];
|
||||
|
||||
/**
|
||||
* Load the docs form the cache if existing
|
||||
* otherwise generate and store in the cache.
|
||||
*/
|
||||
public static function generateConsideringCache(): Collection
|
||||
{
|
||||
$appVersion = trim(file_get_contents(base_path('version')));
|
||||
$cacheKey = 'api-docs::' . $appVersion;
|
||||
if (Cache::has($cacheKey) && config('app.env') === 'production') {
|
||||
$docs = Cache::get($cacheKey);
|
||||
} else {
|
||||
$docs = (new ApiDocsGenerator())->generate();
|
||||
Cache::put($cacheKey, $docs, 60 * 24);
|
||||
}
|
||||
|
||||
return $docs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate API documentation.
|
||||
*/
|
||||
protected function generate(): Collection
|
||||
public function generate(): Collection
|
||||
{
|
||||
$apiRoutes = $this->getFlatApiRoutes();
|
||||
$apiRoutes = $this->loadDetailsFromControllers($apiRoutes);
|
||||
$apiRoutes = $this->loadDetailsFromFiles($apiRoutes);
|
||||
$apiRoutes = $apiRoutes->groupBy('base_model');
|
||||
|
||||
return $apiRoutes;
|
||||
}
|
||||
|
||||
@@ -57,18 +32,11 @@ class ApiDocsGenerator
|
||||
{
|
||||
return $routes->map(function (array $route) {
|
||||
$exampleTypes = ['request', 'response'];
|
||||
$fileTypes = ['json', 'http'];
|
||||
foreach ($exampleTypes as $exampleType) {
|
||||
foreach ($fileTypes as $fileType) {
|
||||
$exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}." . $fileType);
|
||||
if (file_exists($exampleFile)) {
|
||||
$route["example_{$exampleType}"] = file_get_contents($exampleFile);
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
$route["example_{$exampleType}"] = null;
|
||||
$exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}.json");
|
||||
$exampleContent = file_exists($exampleFile) ? file_get_contents($exampleFile) : null;
|
||||
$route["example_{$exampleType}"] = $exampleContent;
|
||||
}
|
||||
|
||||
return $route;
|
||||
});
|
||||
}
|
||||
@@ -83,15 +51,13 @@ class ApiDocsGenerator
|
||||
$comment = $method->getDocComment();
|
||||
$route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null;
|
||||
$route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
|
||||
|
||||
return $route;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load body params and their rules by inspecting the given class and method name.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
protected function getBodyParamsFromClass(string $className, string $methodName): ?array
|
||||
{
|
||||
@@ -102,51 +68,25 @@ class ApiDocsGenerator
|
||||
$this->controllerClasses[$className] = $class;
|
||||
}
|
||||
|
||||
$rules = collect($class->getValidationRules()[$methodName] ?? [])->map(function ($validations) {
|
||||
return array_map(function ($validation) {
|
||||
return $this->getValidationAsString($validation);
|
||||
}, $validations);
|
||||
})->toArray();
|
||||
|
||||
return empty($rules) ? null : $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given validation message to a readable string.
|
||||
*/
|
||||
protected function getValidationAsString($validation): string
|
||||
{
|
||||
if (is_string($validation)) {
|
||||
return $validation;
|
||||
$rules = $class->getValdationRules()[$methodName] ?? [];
|
||||
foreach ($rules as $param => $ruleString) {
|
||||
$rules[$param] = explode('|', $ruleString);
|
||||
}
|
||||
|
||||
if (is_object($validation) && method_exists($validation, '__toString')) {
|
||||
return strval($validation);
|
||||
}
|
||||
|
||||
if ($validation instanceof Password) {
|
||||
return 'min:8';
|
||||
}
|
||||
|
||||
$class = get_class($validation);
|
||||
|
||||
throw new Exception("Cannot provide string representation of rule for class: {$class}");
|
||||
return count($rules) > 0 ? $rules : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse out the description text from a class method comment.
|
||||
*/
|
||||
protected function parseDescriptionFromMethodComment(string $comment): string
|
||||
protected function parseDescriptionFromMethodComment(string $comment)
|
||||
{
|
||||
$matches = [];
|
||||
preg_match_all('/^\s*?\*\s((?![@\s]).*?)$/m', $comment, $matches);
|
||||
|
||||
return implode(' ', $matches[1] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reflection method from the given class name and method name.
|
||||
*
|
||||
* @throws ReflectionException
|
||||
*/
|
||||
protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
|
||||
@@ -171,16 +111,15 @@ class ApiDocsGenerator
|
||||
[$controller, $controllerMethod] = explode('@', $route->action['uses']);
|
||||
$baseModelName = explode('.', explode('/', $route->uri)[1])[0];
|
||||
$shortName = $baseModelName . '-' . $controllerMethod;
|
||||
|
||||
return [
|
||||
'name' => $shortName,
|
||||
'uri' => $route->uri,
|
||||
'method' => $route->methods[0],
|
||||
'controller' => $controller,
|
||||
'controller_method' => $controllerMethod,
|
||||
'controller_method_kebab' => Str::kebab($controllerMethod),
|
||||
'base_model' => $baseModelName,
|
||||
'name' => $shortName,
|
||||
'uri' => $route->uri,
|
||||
'method' => $route->methods[0],
|
||||
'controller' => $controller,
|
||||
'controller_method' => $controllerMethod,
|
||||
'base_model' => $baseModelName,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class ApiEntityListFormatter
|
||||
{
|
||||
/**
|
||||
* The list to be formatted.
|
||||
* @var Entity[]
|
||||
*/
|
||||
protected $list = [];
|
||||
|
||||
/**
|
||||
* The fields to show in the formatted data.
|
||||
* Can be a plain string array item for a direct model field (If existing on model).
|
||||
* If the key is a string, with a callable value, the return value of the callable
|
||||
* will be used for the resultant value. A null return value will omit the property.
|
||||
* @var array<string|int, string|callable>
|
||||
*/
|
||||
protected $fields = [
|
||||
'id', 'name', 'slug', 'book_id', 'chapter_id',
|
||||
'draft', 'template', 'created_at', 'updated_at',
|
||||
];
|
||||
|
||||
public function __construct(array $list)
|
||||
{
|
||||
$this->list = $list;
|
||||
|
||||
// Default dynamic fields
|
||||
$this->withField('url', fn(Entity $entity) => $entity->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a field to be used in the formatter, with the property using the given
|
||||
* name and value being the return type of the given callback.
|
||||
*/
|
||||
public function withField(string $property, callable $callback): self
|
||||
{
|
||||
$this->fields[$property] = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the 'type' property in the response reflecting the entity type.
|
||||
* EG: page, chapter, bookshelf, book
|
||||
* To be included in results with non-pre-determined types.
|
||||
*/
|
||||
public function withType(): self
|
||||
{
|
||||
$this->withField('type', fn(Entity $entity) => $entity->getType());
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Include tags in the formatted data.
|
||||
*/
|
||||
public function withTags(): self
|
||||
{
|
||||
$this->withField('tags', fn(Entity $entity) => $entity->tags);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the data and return an array of formatted content.
|
||||
* @return array[]
|
||||
*/
|
||||
public function format(): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($this->list as $item) {
|
||||
$results[] = $this->formatSingle($item);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single entity item to a plain array.
|
||||
*/
|
||||
protected function formatSingle(Entity $entity): array
|
||||
{
|
||||
$result = [];
|
||||
$values = (clone $entity)->toArray();
|
||||
|
||||
foreach ($this->fields as $field => $callback) {
|
||||
if (is_string($callback)) {
|
||||
$field = $callback;
|
||||
if (!isset($values[$field])) {
|
||||
continue;
|
||||
}
|
||||
$value = $values[$field];
|
||||
} else {
|
||||
$value = $callback($entity);
|
||||
if (is_null($value)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$result[$field] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Api;
|
||||
<?php namespace BookStack\Api;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Class ApiToken.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $token_id
|
||||
* @property string $secret
|
||||
* @property string $name
|
||||
* @property Carbon $expires_at
|
||||
* @property User $user
|
||||
*/
|
||||
class ApiToken extends Model implements Loggable
|
||||
class ApiToken extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'expires_at'];
|
||||
protected $casts = [
|
||||
'expires_at' => 'date:Y-m-d',
|
||||
'expires_at' => 'date:Y-m-d'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -41,12 +28,4 @@ class ApiToken extends Model implements Loggable
|
||||
{
|
||||
return Carbon::now()->addYears(100)->format('Y-m-d');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "({$this->id}) {$this->name}; User: {$this->user->logDescriptor()}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Exceptions\ApiAuthException;
|
||||
use Illuminate\Auth\GuardHelpers;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
@@ -13,6 +12,7 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class ApiTokenGuard implements Guard
|
||||
{
|
||||
|
||||
use GuardHelpers;
|
||||
|
||||
/**
|
||||
@@ -20,14 +20,9 @@ class ApiTokenGuard implements Guard
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* @var LoginService
|
||||
*/
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* The last auth exception thrown in this request.
|
||||
*
|
||||
* @var ApiAuthException
|
||||
*/
|
||||
protected $lastAuthException;
|
||||
@@ -35,14 +30,13 @@ class ApiTokenGuard implements Guard
|
||||
/**
|
||||
* ApiTokenGuard constructor.
|
||||
*/
|
||||
public function __construct(Request $request, LoginService $loginService)
|
||||
public function __construct(Request $request)
|
||||
{
|
||||
$this->request = $request;
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
@@ -53,7 +47,6 @@ class ApiTokenGuard implements Guard
|
||||
}
|
||||
|
||||
$user = null;
|
||||
|
||||
try {
|
||||
$user = $this->getAuthorisedUserFromRequest();
|
||||
} catch (ApiAuthException $exception) {
|
||||
@@ -61,20 +54,19 @@ class ApiTokenGuard implements Guard
|
||||
}
|
||||
|
||||
$this->user = $user;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if current user is authenticated. If not, throw an exception.
|
||||
*
|
||||
* @throws ApiAuthException
|
||||
*
|
||||
* @return \Illuminate\Contracts\Auth\Authenticatable
|
||||
*
|
||||
* @throws ApiAuthException
|
||||
*/
|
||||
public function authenticate()
|
||||
{
|
||||
if (!is_null($user = $this->user())) {
|
||||
if (! is_null($user = $this->user())) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
@@ -87,7 +79,6 @@ class ApiTokenGuard implements Guard
|
||||
|
||||
/**
|
||||
* Check the API token in the request and fetch a valid authorised user.
|
||||
*
|
||||
* @throws ApiAuthException
|
||||
*/
|
||||
protected function getAuthorisedUserFromRequest(): Authenticatable
|
||||
@@ -102,16 +93,11 @@ class ApiTokenGuard implements Guard
|
||||
|
||||
$this->validateToken($token, $secret);
|
||||
|
||||
if ($this->loginService->awaitingEmailConfirmation($token->user)) {
|
||||
throw new ApiAuthException(trans('errors.email_confirmation_awaiting'));
|
||||
}
|
||||
|
||||
return $token->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the format of the token header value string.
|
||||
*
|
||||
* @throws ApiAuthException
|
||||
*/
|
||||
protected function validateTokenHeaderValue(string $authToken): void
|
||||
@@ -128,7 +114,6 @@ class ApiTokenGuard implements Guard
|
||||
/**
|
||||
* Validate the given secret against the given token and ensure the token
|
||||
* currently has access to the instance API.
|
||||
*
|
||||
* @throws ApiAuthException
|
||||
*/
|
||||
protected function validateToken(?ApiToken $token, string $secret): void
|
||||
@@ -152,7 +137,7 @@ class ApiTokenGuard implements Guard
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function validate(array $credentials = [])
|
||||
{
|
||||
@@ -178,4 +163,4 @@ class ApiTokenGuard implements Guard
|
||||
{
|
||||
$this->user = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Api;
|
||||
<?php namespace BookStack\Api;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ListingResponseBuilder
|
||||
{
|
||||
protected Builder $query;
|
||||
protected Request $request;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $fields;
|
||||
protected $query;
|
||||
protected $request;
|
||||
protected $fields;
|
||||
|
||||
/**
|
||||
* @var array<callable>
|
||||
*/
|
||||
protected array $resultModifiers = [];
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $filterOperators = [
|
||||
protected $filterOperators = [
|
||||
'eq' => '=',
|
||||
'ne' => '!=',
|
||||
'gt' => '>',
|
||||
'lt' => '<',
|
||||
'gte' => '>=',
|
||||
'lte' => '<=',
|
||||
'like' => 'like',
|
||||
'like' => 'like'
|
||||
];
|
||||
|
||||
/**
|
||||
* ListingResponseBuilder constructor.
|
||||
* The given fields will be forced visible within the model results.
|
||||
*/
|
||||
public function __construct(Builder $query, Request $request, array $fields)
|
||||
{
|
||||
@@ -50,53 +34,37 @@ class ListingResponseBuilder
|
||||
/**
|
||||
* Get the response from this builder.
|
||||
*/
|
||||
public function toResponse(): JsonResponse
|
||||
public function toResponse()
|
||||
{
|
||||
$filteredQuery = $this->filterQuery($this->query);
|
||||
|
||||
$total = $filteredQuery->count();
|
||||
$data = $this->fetchData($filteredQuery)->each(function ($model) {
|
||||
foreach ($this->resultModifiers as $modifier) {
|
||||
$modifier($model);
|
||||
}
|
||||
});
|
||||
$data = $this->fetchData();
|
||||
$total = $this->query->count();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'data' => $data,
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a callback to modify each element of the results.
|
||||
*
|
||||
* @param (callable(Model): void) $modifier
|
||||
* Fetch the data to return in the response.
|
||||
*/
|
||||
public function modifyResults(callable $modifier): void
|
||||
protected function fetchData(): Collection
|
||||
{
|
||||
$this->resultModifiers[] = $modifier;
|
||||
}
|
||||
$this->applyCountAndOffset($this->query);
|
||||
$this->applySorting($this->query);
|
||||
$this->applyFiltering($this->query);
|
||||
|
||||
/**
|
||||
* Fetch the data to return within the response.
|
||||
*/
|
||||
protected function fetchData(Builder $query): Collection
|
||||
{
|
||||
$query = $this->countAndOffsetQuery($query);
|
||||
$query = $this->sortQuery($query);
|
||||
|
||||
return $query->get($this->fields);
|
||||
return $this->query->get($this->fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply any filtering operations found in the request.
|
||||
*/
|
||||
protected function filterQuery(Builder $query): Builder
|
||||
protected function applyFiltering(Builder $query)
|
||||
{
|
||||
$query = clone $query;
|
||||
$requestFilters = $this->request->get('filter', []);
|
||||
if (!is_array($requestFilters)) {
|
||||
return $query;
|
||||
return;
|
||||
}
|
||||
|
||||
$queryFilters = collect($requestFilters)->map(function ($value, $key) {
|
||||
@@ -105,7 +73,7 @@ class ListingResponseBuilder
|
||||
return !is_null($value);
|
||||
})->values()->toArray();
|
||||
|
||||
return $query->where($queryFilters);
|
||||
$query->where($queryFilters);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +94,6 @@ class ListingResponseBuilder
|
||||
}
|
||||
|
||||
$queryOperator = $this->filterOperators[$filterOperator];
|
||||
|
||||
return [$field, $queryOperator, $value];
|
||||
}
|
||||
|
||||
@@ -134,9 +101,8 @@ class ListingResponseBuilder
|
||||
* Apply sorting operations to the query from given parameters
|
||||
* otherwise falling back to the first given field, ascending.
|
||||
*/
|
||||
protected function sortQuery(Builder $query): Builder
|
||||
protected function applySorting(Builder $query)
|
||||
{
|
||||
$query = clone $query;
|
||||
$defaultSortName = $this->fields[0];
|
||||
$direction = 'asc';
|
||||
|
||||
@@ -150,21 +116,20 @@ class ListingResponseBuilder
|
||||
$sortName = $defaultSortName;
|
||||
}
|
||||
|
||||
return $query->orderBy($sortName, $direction);
|
||||
$query->orderBy($sortName, $direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply count and offset for paging, based on params from the request while falling
|
||||
* back to system defined default, taking the max limit into account.
|
||||
*/
|
||||
protected function countAndOffsetQuery(Builder $query): Builder
|
||||
protected function applyCountAndOffset(Builder $query)
|
||||
{
|
||||
$query = clone $query;
|
||||
$offset = max(0, $this->request->get('offset', 0));
|
||||
$maxCount = config('api.max_item_count');
|
||||
$count = $this->request->get('count', config('api.default_item_count'));
|
||||
$count = max(min($maxCount, $count), 1);
|
||||
|
||||
return $query->skip($offset)->take($count);
|
||||
$query->skip($offset)->take($count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ namespace BookStack;
|
||||
|
||||
class Application extends \Illuminate\Foundation\Application
|
||||
{
|
||||
|
||||
/**
|
||||
* Get the path to the application configuration files.
|
||||
*
|
||||
* @param string $path Optionally, a path to append to the config path
|
||||
*
|
||||
* @param string $path Optionally, a path to append to the config path
|
||||
* @return string
|
||||
*/
|
||||
public function configPath($path = '')
|
||||
@@ -18,6 +18,6 @@ class Application extends \Illuminate\Foundation\Application
|
||||
. 'app'
|
||||
. DIRECTORY_SEPARATOR
|
||||
. 'Config'
|
||||
. ($path ? DIRECTORY_SEPARATOR . $path : $path);
|
||||
. ($path ? DIRECTORY_SEPARATOR.$path : $path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access;
|
||||
<?php namespace BookStack\Auth\Access;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\ConfirmationEmailException;
|
||||
@@ -14,7 +12,7 @@ class EmailConfirmationService extends UserTokenService
|
||||
/**
|
||||
* Create new confirmation for a user,
|
||||
* Also removes any existing old ones.
|
||||
*
|
||||
* @param User $user
|
||||
* @throws ConfirmationEmailException
|
||||
*/
|
||||
public function sendConfirmation(User $user)
|
||||
@@ -31,8 +29,9 @@ class EmailConfirmationService extends UserTokenService
|
||||
|
||||
/**
|
||||
* Check if confirmation is required in this instance.
|
||||
* @return bool
|
||||
*/
|
||||
public function confirmationRequired(): bool
|
||||
public function confirmationRequired() : bool
|
||||
{
|
||||
return setting('registration-confirmation')
|
||||
|| setting('registration-restrict');
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access;
|
||||
<?php namespace BookStack\Auth\Access;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class GroupSyncService
|
||||
class ExternalAuthService
|
||||
{
|
||||
/**
|
||||
* Check a role against an array of group names to see if it matches.
|
||||
@@ -19,7 +17,6 @@ class GroupSyncService
|
||||
}
|
||||
|
||||
$roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
|
||||
|
||||
return in_array($roleName, $groupNames);
|
||||
}
|
||||
|
||||
@@ -28,8 +25,10 @@ class GroupSyncService
|
||||
*/
|
||||
protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool
|
||||
{
|
||||
foreach ($this->parseRoleExternalAuthId($externalId) as $externalAuthId) {
|
||||
if (in_array($externalAuthId, $groupNames)) {
|
||||
$externalAuthIds = explode(',', strtolower($externalId));
|
||||
|
||||
foreach ($externalAuthIds as $externalAuthId) {
|
||||
if (in_array(trim($externalAuthId), $groupNames)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -37,29 +36,25 @@ class GroupSyncService
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function parseRoleExternalAuthId(string $externalId): array
|
||||
{
|
||||
$inputIds = preg_split('/(?<!\\\),/', strtolower($externalId));
|
||||
$cleanIds = [];
|
||||
|
||||
foreach ($inputIds as $inputId) {
|
||||
$cleanIds[] = str_replace('\,', ',', trim($inputId));
|
||||
}
|
||||
|
||||
return $cleanIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an array of group names to BookStack system roles.
|
||||
* Formats group names to be lower-case and hyphenated.
|
||||
* @param array $groupNames
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
protected function matchGroupsToSystemsRoles(array $groupNames): Collection
|
||||
protected function matchGroupsToSystemsRoles(array $groupNames)
|
||||
{
|
||||
foreach ($groupNames as $i => $groupName) {
|
||||
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
|
||||
}
|
||||
|
||||
$roles = Role::query()->get(['id', 'external_auth_id', 'display_name']);
|
||||
$roles = Role::query()->where(function (Builder $query) use ($groupNames) {
|
||||
$query->whereIn('name', $groupNames);
|
||||
foreach ($groupNames as $groupName) {
|
||||
$query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
|
||||
}
|
||||
})->get();
|
||||
|
||||
$matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
|
||||
return $this->roleMatchesGroupNames($role, $groupNames);
|
||||
});
|
||||
@@ -68,15 +63,15 @@ class GroupSyncService
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the groups to the user roles for the current user.
|
||||
* Sync the groups to the user roles for the current user
|
||||
*/
|
||||
public function syncUserWithFoundGroups(User $user, array $userGroups, bool $detachExisting): void
|
||||
public function syncWithGroups(User $user, array $userGroups): void
|
||||
{
|
||||
// Get the ids for the roles from the names
|
||||
$groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups);
|
||||
|
||||
// Sync groups
|
||||
if ($detachExisting) {
|
||||
if ($this->config['remove_from_groups']) {
|
||||
$user->roles()->sync($groupsAsRoles);
|
||||
$user->attachDefaultRole();
|
||||
} else {
|
||||
@@ -4,10 +4,10 @@ namespace BookStack\Auth\Access;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\UserProvider;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ExternalBaseUserProvider implements UserProvider
|
||||
{
|
||||
|
||||
/**
|
||||
* The user model.
|
||||
*
|
||||
@@ -17,6 +17,7 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
|
||||
/**
|
||||
* LdapUserProvider constructor.
|
||||
* @param $model
|
||||
*/
|
||||
public function __construct(string $model)
|
||||
{
|
||||
@@ -26,21 +27,19 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
/**
|
||||
* Create a new instance of the model.
|
||||
*
|
||||
* @return Model
|
||||
* @return \Illuminate\Database\Eloquent\Model
|
||||
*/
|
||||
public function createModel()
|
||||
{
|
||||
$class = '\\' . ltrim($this->model, '\\');
|
||||
|
||||
return new $class();
|
||||
return new $class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user by their unique identifier.
|
||||
*
|
||||
* @param mixed $identifier
|
||||
*
|
||||
* @return Authenticatable|null
|
||||
* @param mixed $identifier
|
||||
* @return \Illuminate\Contracts\Auth\Authenticatable|null
|
||||
*/
|
||||
public function retrieveById($identifier)
|
||||
{
|
||||
@@ -50,22 +49,21 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
/**
|
||||
* Retrieve a user by their unique identifier and "remember me" token.
|
||||
*
|
||||
* @param mixed $identifier
|
||||
* @param string $token
|
||||
*
|
||||
* @return Authenticatable|null
|
||||
* @param mixed $identifier
|
||||
* @param string $token
|
||||
* @return \Illuminate\Contracts\Auth\Authenticatable|null
|
||||
*/
|
||||
public function retrieveByToken($identifier, $token)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the "remember me" token for the given user in storage.
|
||||
*
|
||||
* @param Authenticatable $user
|
||||
* @param string $token
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable $user
|
||||
* @param string $token
|
||||
* @return void
|
||||
*/
|
||||
public function updateRememberToken(Authenticatable $user, $token)
|
||||
@@ -76,15 +74,13 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
/**
|
||||
* Retrieve a user by the given credentials.
|
||||
*
|
||||
* @param array $credentials
|
||||
*
|
||||
* @return Authenticatable|null
|
||||
* @param array $credentials
|
||||
* @return \Illuminate\Contracts\Auth\Authenticatable|null
|
||||
*/
|
||||
public function retrieveByCredentials(array $credentials)
|
||||
{
|
||||
// Search current user base by looking up a uid
|
||||
$model = $this->createModel();
|
||||
|
||||
return $model->newQuery()
|
||||
->where('external_auth_id', $credentials['external_auth_id'])
|
||||
->first();
|
||||
@@ -93,9 +89,8 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
/**
|
||||
* Validate a user against the given credentials.
|
||||
*
|
||||
* @param Authenticatable $user
|
||||
* @param array $credentials
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable $user
|
||||
* @param array $credentials
|
||||
* @return bool
|
||||
*/
|
||||
public function validateCredentials(Authenticatable $user, array $credentials)
|
||||
|
||||
@@ -15,6 +15,8 @@ use Illuminate\Contracts\Session\Session;
|
||||
* guard with 'remember' functionality removed. Basic auth and event emission
|
||||
* has also been removed to keep this simple. Designed to be extended by external
|
||||
* Auth Guards.
|
||||
*
|
||||
* @package Illuminate\Auth
|
||||
*/
|
||||
class ExternalBaseSessionGuard implements StatefulGuard
|
||||
{
|
||||
@@ -84,7 +86,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
||||
// If we've already retrieved the user for the current request we can just
|
||||
// return it back immediately. We do not want to fetch the user data on
|
||||
// every call to this method because that would be tremendously slow.
|
||||
if (!is_null($this->user)) {
|
||||
if (! is_null($this->user)) {
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
@@ -92,7 +94,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
||||
|
||||
// First we will try to load the user using the
|
||||
// identifier in the session if one exists.
|
||||
if (!is_null($id)) {
|
||||
if (! is_null($id)) {
|
||||
$this->user = $this->provider->retrieveById($id);
|
||||
}
|
||||
|
||||
@@ -118,8 +120,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
||||
/**
|
||||
* Log a user into the application without sessions or cookies.
|
||||
*
|
||||
* @param array $credentials
|
||||
*
|
||||
* @param array $credentials
|
||||
* @return bool
|
||||
*/
|
||||
public function once(array $credentials = [])
|
||||
@@ -136,13 +137,12 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
||||
/**
|
||||
* Log the given user ID into the application without sessions or cookies.
|
||||
*
|
||||
* @param mixed $id
|
||||
*
|
||||
* @param mixed $id
|
||||
* @return \Illuminate\Contracts\Auth\Authenticatable|false
|
||||
*/
|
||||
public function onceUsingId($id)
|
||||
{
|
||||
if (!is_null($user = $this->provider->retrieveById($id))) {
|
||||
if (! is_null($user = $this->provider->retrieveById($id))) {
|
||||
$this->setUser($user);
|
||||
|
||||
return $user;
|
||||
@@ -154,8 +154,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
||||
/**
|
||||
* Validate a user's credentials.
|
||||
*
|
||||
* @param array $credentials
|
||||
*
|
||||
* @param array $credentials
|
||||
* @return bool
|
||||
*/
|
||||
public function validate(array $credentials = [])
|
||||
@@ -163,12 +162,12 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Attempt to authenticate a user using the given credentials.
|
||||
*
|
||||
* @param array $credentials
|
||||
* @param bool $remember
|
||||
*
|
||||
* @param array $credentials
|
||||
* @param bool $remember
|
||||
* @return bool
|
||||
*/
|
||||
public function attempt(array $credentials = [], $remember = false)
|
||||
@@ -179,24 +178,26 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
||||
/**
|
||||
* Log the given user ID into the application.
|
||||
*
|
||||
* @param mixed $id
|
||||
* @param bool $remember
|
||||
*
|
||||
* @param mixed $id
|
||||
* @param bool $remember
|
||||
* @return \Illuminate\Contracts\Auth\Authenticatable|false
|
||||
*/
|
||||
public function loginUsingId($id, $remember = false)
|
||||
{
|
||||
// Always return false as to disable this method,
|
||||
// Logins should route through LoginService.
|
||||
if (! is_null($user = $this->provider->retrieveById($id))) {
|
||||
$this->login($user, $remember);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a user into the application.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable $user
|
||||
* @param bool $remember
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable $user
|
||||
* @param bool $remember
|
||||
* @return void
|
||||
*/
|
||||
public function login(AuthenticatableContract $user, $remember = false)
|
||||
@@ -209,8 +210,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
||||
/**
|
||||
* Update the session with the given ID.
|
||||
*
|
||||
* @param string $id
|
||||
*
|
||||
* @param string $id
|
||||
* @return void
|
||||
*/
|
||||
protected function updateSession($id)
|
||||
@@ -264,7 +264,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
||||
*/
|
||||
public function getName()
|
||||
{
|
||||
return 'login_' . $this->name . '_' . sha1(static::class);
|
||||
return 'login_'.$this->name.'_'.sha1(static::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,8 +290,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
||||
/**
|
||||
* Set the current user.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable $user
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable $user
|
||||
* @return $this
|
||||
*/
|
||||
public function setUser(AuthenticatableContract $user)
|
||||
@@ -302,4 +301,5 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,29 +5,31 @@ namespace BookStack\Auth\Access\Guards;
|
||||
use BookStack\Auth\Access\LdapService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Exceptions\LdapException;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use Illuminate\Contracts\Auth\UserProvider;
|
||||
use Illuminate\Contracts\Session\Session;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
{
|
||||
protected LdapService $ldapService;
|
||||
|
||||
protected $ldapService;
|
||||
|
||||
/**
|
||||
* LdapSessionGuard constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
$name,
|
||||
public function __construct($name,
|
||||
UserProvider $provider,
|
||||
Session $session,
|
||||
LdapService $ldapService,
|
||||
RegistrationService $registrationService
|
||||
) {
|
||||
)
|
||||
{
|
||||
$this->ldapService = $ldapService;
|
||||
parent::__construct($name, $provider, $session, $registrationService);
|
||||
}
|
||||
@@ -36,10 +38,8 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
* Validate a user's credentials.
|
||||
*
|
||||
* @param array $credentials
|
||||
*
|
||||
* @throws LdapException
|
||||
*
|
||||
* @return bool
|
||||
* @throws LdapException
|
||||
*/
|
||||
public function validate(array $credentials = [])
|
||||
{
|
||||
@@ -47,7 +47,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
|
||||
if (isset($userDetails['uid'])) {
|
||||
$this->lastAttempted = $this->provider->retrieveByCredentials([
|
||||
'external_auth_id' => $userDetails['uid'],
|
||||
'external_auth_id' => $userDetails['uid']
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -58,13 +58,12 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
* Attempt to authenticate a user using the given credentials.
|
||||
*
|
||||
* @param array $credentials
|
||||
* @param bool $remember
|
||||
*
|
||||
* @throws LdapException*@throws \BookStack\Exceptions\JsonDebugException
|
||||
* @throws LoginAttemptException
|
||||
* @throws JsonDebugException
|
||||
*
|
||||
* @param bool $remember
|
||||
* @return bool
|
||||
* @throws LoginAttemptEmailNeededException
|
||||
* @throws LoginAttemptException
|
||||
* @throws LdapException
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
public function attempt(array $credentials = [], $remember = false)
|
||||
{
|
||||
@@ -74,7 +73,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
$user = null;
|
||||
if (isset($userDetails['uid'])) {
|
||||
$this->lastAttempted = $user = $this->provider->retrieveByCredentials([
|
||||
'external_auth_id' => $userDetails['uid'],
|
||||
'external_auth_id' => $userDetails['uid']
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -83,11 +82,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
}
|
||||
|
||||
if (is_null($user)) {
|
||||
try {
|
||||
$user = $this->createNewFromLdapAndCreds($userDetails, $credentials);
|
||||
} catch (UserRegistrationException $exception) {
|
||||
throw new LoginAttemptException($exception->getMessage());
|
||||
}
|
||||
$user = $this->createNewFromLdapAndCreds($userDetails, $credentials);
|
||||
}
|
||||
|
||||
// Sync LDAP groups if required
|
||||
@@ -95,19 +90,12 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
$this->ldapService->syncGroups($user, $username);
|
||||
}
|
||||
|
||||
// Attach avatar if non-existent
|
||||
if (!$user->avatar()->exists()) {
|
||||
$this->ldapService->saveAndAttachAvatar($user, $userDetails);
|
||||
}
|
||||
|
||||
$this->login($user, $remember);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user from the given ldap credentials and login credentials.
|
||||
*
|
||||
* Create a new user from the given ldap credentials and login credentials
|
||||
* @throws LoginAttemptEmailNeededException
|
||||
* @throws LoginAttemptException
|
||||
* @throws UserRegistrationException
|
||||
@@ -121,15 +109,13 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
}
|
||||
|
||||
$details = [
|
||||
'name' => $ldapUserDetails['name'],
|
||||
'email' => $ldapUserDetails['email'] ?: $credentials['email'],
|
||||
'name' => $ldapUserDetails['name'],
|
||||
'email' => $ldapUserDetails['email'] ?: $credentials['email'],
|
||||
'external_auth_id' => $ldapUserDetails['uid'],
|
||||
'password' => Str::random(32),
|
||||
'password' => Str::random(32),
|
||||
];
|
||||
|
||||
$user = $this->registrationService->registerUser($details, null, false);
|
||||
$this->ldapService->saveAndAttachAvatar($user, $ldapUserDetails);
|
||||
|
||||
return $user;
|
||||
return $this->registrationService->registerUser($details, null, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,20 +3,21 @@
|
||||
namespace BookStack\Auth\Access\Guards;
|
||||
|
||||
/**
|
||||
* Saml2 Session Guard.
|
||||
* Saml2 Session Guard
|
||||
*
|
||||
* The saml2 login process 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 Saml2 controller & Saml2Service. This class provides a safer, thin
|
||||
* version of SessionGuard.
|
||||
*
|
||||
* @package BookStack\Auth\Access\Guards
|
||||
*/
|
||||
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
|
||||
class Saml2SessionGuard extends ExternalBaseSessionGuard
|
||||
{
|
||||
/**
|
||||
* Validate a user's credentials.
|
||||
*
|
||||
* @param array $credentials
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function validate(array $credentials = [])
|
||||
@@ -28,12 +29,12 @@ class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
|
||||
* Attempt to authenticate a user using the given credentials.
|
||||
*
|
||||
* @param array $credentials
|
||||
* @param bool $remember
|
||||
*
|
||||
* @param bool $remember
|
||||
* @return bool
|
||||
*/
|
||||
public function attempt(array $credentials = [], $remember = false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,61 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access;
|
||||
<?php namespace BookStack\Auth\Access;
|
||||
|
||||
/**
|
||||
* Class Ldap
|
||||
* An object-orientated thin abstraction wrapper for common PHP LDAP functions.
|
||||
* Allows the standard LDAP functions to be mocked for testing.
|
||||
* @package BookStack\Services
|
||||
*/
|
||||
class Ldap
|
||||
{
|
||||
|
||||
/**
|
||||
* Connect to an LDAP server.
|
||||
*
|
||||
* Connect to a LDAP server.
|
||||
* @param string $hostName
|
||||
* @param int $port
|
||||
* @return resource
|
||||
*/
|
||||
public function connect(string $hostName, int $port)
|
||||
public function connect($hostName, $port)
|
||||
{
|
||||
return ldap_connect($hostName, $port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of a LDAP option for the given connection.
|
||||
*
|
||||
* @param resource $ldapConnection
|
||||
* @param mixed $value
|
||||
* @param int $option
|
||||
* @param mixed $value
|
||||
* @return bool
|
||||
*/
|
||||
public function setOption($ldapConnection, int $option, $value): bool
|
||||
public function setOption($ldapConnection, $option, $value)
|
||||
{
|
||||
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 $ldapConnection
|
||||
* @param $ldapConnection
|
||||
* @param $version
|
||||
* @return bool
|
||||
*/
|
||||
public function setVersion($ldapConnection, int $version): bool
|
||||
public function setVersion($ldapConnection, $version)
|
||||
{
|
||||
return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search LDAP tree using the provided filter.
|
||||
*
|
||||
* @param resource $ldapConnection
|
||||
* @param string $baseDn
|
||||
* @param string $filter
|
||||
* @param array|null $attributes
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
public function search($ldapConnection, $baseDn, $filter, array $attributes = null)
|
||||
@@ -65,10 +58,8 @@ class Ldap
|
||||
|
||||
/**
|
||||
* Get entries from an ldap search result.
|
||||
*
|
||||
* @param resource $ldapConnection
|
||||
* @param resource $ldapSearchResult
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getEntries($ldapConnection, $ldapSearchResult)
|
||||
@@ -78,28 +69,23 @@ class Ldap
|
||||
|
||||
/**
|
||||
* Search and get entries immediately.
|
||||
*
|
||||
* @param resource $ldapConnection
|
||||
* @param string $baseDn
|
||||
* @param string $filter
|
||||
* @param array|null $attributes
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
public function searchAndGetEntries($ldapConnection, $baseDn, $filter, array $attributes = null)
|
||||
{
|
||||
$search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
|
||||
|
||||
return $this->getEntries($ldapConnection, $search);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind to LDAP directory.
|
||||
*
|
||||
* @param resource $ldapConnection
|
||||
* @param string $bindRdn
|
||||
* @param string $bindPassword
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function bind($ldapConnection, $bindRdn = null, $bindPassword = null)
|
||||
@@ -109,10 +95,8 @@ class Ldap
|
||||
|
||||
/**
|
||||
* Explode a LDAP dn string into an array of components.
|
||||
*
|
||||
* @param string $dn
|
||||
* @param int $withAttrib
|
||||
*
|
||||
* @param int $withAttrib
|
||||
* @return array
|
||||
*/
|
||||
public function explodeDn(string $dn, int $withAttrib)
|
||||
@@ -122,14 +106,12 @@ class Ldap
|
||||
|
||||
/**
|
||||
* Escape a string for use in an LDAP filter.
|
||||
*
|
||||
* @param string $value
|
||||
* @param string $ignore
|
||||
* @param int $flags
|
||||
*
|
||||
* @param int $flags
|
||||
* @return string
|
||||
*/
|
||||
public function escape(string $value, string $ignore = '', int $flags = 0)
|
||||
public function escape(string $value, string $ignore = "", int $flags = 0)
|
||||
{
|
||||
return ldap_escape($value, $ignore, $flags);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access;
|
||||
<?php namespace BookStack\Auth\Access;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\LdapException;
|
||||
use BookStack\Uploads\UserAvatars;
|
||||
use ErrorException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Class LdapService
|
||||
* Handles any app-specific LDAP tasks.
|
||||
*/
|
||||
class LdapService
|
||||
class LdapService extends ExternalAuthService
|
||||
{
|
||||
protected Ldap $ldap;
|
||||
protected GroupSyncService $groupSyncService;
|
||||
protected UserAvatars $userAvatars;
|
||||
|
||||
/**
|
||||
* @var resource
|
||||
*/
|
||||
protected $ldap;
|
||||
protected $ldapConnection;
|
||||
|
||||
protected array $config;
|
||||
protected bool $enabled;
|
||||
protected $config;
|
||||
protected $enabled;
|
||||
|
||||
/**
|
||||
* LdapService constructor.
|
||||
*/
|
||||
public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
|
||||
public function __construct(Ldap $ldap)
|
||||
{
|
||||
$this->ldap = $ldap;
|
||||
$this->userAvatars = $userAvatars;
|
||||
$this->groupSyncService = $groupSyncService;
|
||||
$this->config = config('services.ldap');
|
||||
$this->enabled = config('auth.method') === 'ldap';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if groups should be synced.
|
||||
* @return bool
|
||||
*/
|
||||
public function shouldSyncGroups(): bool
|
||||
public function shouldSyncGroups()
|
||||
{
|
||||
return $this->enabled && $this->config['user_to_groups'] !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for attributes for a specific user on the ldap.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
private function getUserWithAttributes(string $userName, array $attributes): ?array
|
||||
@@ -81,7 +69,6 @@ class LdapService
|
||||
/**
|
||||
* Get the details of a user from LDAP using the given username.
|
||||
* User found via configurable user filter.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
public function getUserDetails(string $userName): ?array
|
||||
@@ -89,13 +76,10 @@ class LdapService
|
||||
$idAttr = $this->config['id_attribute'];
|
||||
$emailAttr = $this->config['email_attribute'];
|
||||
$displayNameAttr = $this->config['display_name_attribute'];
|
||||
$thumbnailAttr = $this->config['thumbnail_attribute'];
|
||||
|
||||
$user = $this->getUserWithAttributes($userName, array_filter([
|
||||
'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr,
|
||||
]));
|
||||
$user = $this->getUserWithAttributes($userName, ['cn', 'dn', $idAttr, $emailAttr, $displayNameAttr]);
|
||||
|
||||
if (is_null($user)) {
|
||||
if ($user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -105,12 +89,11 @@ class LdapService
|
||||
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
|
||||
'dn' => $user['dn'],
|
||||
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
|
||||
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
|
||||
];
|
||||
|
||||
if ($this->config['dump_user_details']) {
|
||||
throw new JsonDebugException([
|
||||
'details_from_ldap' => $user,
|
||||
'details_from_ldap' => $user,
|
||||
'details_bookstack_parsed' => $formatted,
|
||||
]);
|
||||
}
|
||||
@@ -146,7 +129,6 @@ class LdapService
|
||||
|
||||
/**
|
||||
* Check if the given credentials are valid for the given user.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
public function validateUserCredentials(?array $ldapUserDetails, string $password): bool
|
||||
@@ -156,7 +138,6 @@ class LdapService
|
||||
}
|
||||
|
||||
$ldapConnection = $this->getConnection();
|
||||
|
||||
try {
|
||||
$ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password);
|
||||
} catch (ErrorException $e) {
|
||||
@@ -169,9 +150,7 @@ class LdapService
|
||||
/**
|
||||
* Bind the system user to the LDAP connection using the given credentials
|
||||
* otherwise anonymous access is attempted.
|
||||
*
|
||||
* @param resource $connection
|
||||
*
|
||||
* @param $connection
|
||||
* @throws LdapException
|
||||
*/
|
||||
protected function bindSystemUser($connection)
|
||||
@@ -194,10 +173,8 @@ class LdapService
|
||||
/**
|
||||
* Get the connection to the LDAP server.
|
||||
* Creates a new connection if one does not exist.
|
||||
*
|
||||
* @throws LdapException
|
||||
*
|
||||
* @return resource
|
||||
* @throws LdapException
|
||||
*/
|
||||
protected function getConnection()
|
||||
{
|
||||
@@ -210,8 +187,8 @@ class LdapService
|
||||
throw new LdapException(trans('errors.ldap_extension_not_installed'));
|
||||
}
|
||||
|
||||
// Disable certificate verification.
|
||||
// This option works globally and must be set before a connection is created.
|
||||
// Check if TLS_INSECURE is set. The handle is set to NULL due to the nature of
|
||||
// the LDAP_OPT_X_TLS_REQUIRE_CERT option. It can only be set globally and not per handle.
|
||||
if ($this->config['tls_insecure']) {
|
||||
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
|
||||
}
|
||||
@@ -228,16 +205,7 @@ class LdapService
|
||||
$this->ldap->setVersion($ldapConnection, $this->config['version']);
|
||||
}
|
||||
|
||||
// Start and verify TLS if it's enabled
|
||||
if ($this->config['start_tls']) {
|
||||
$started = $this->ldap->startTls($ldapConnection);
|
||||
if (!$started) {
|
||||
throw new LdapException('Could not start TLS connection');
|
||||
}
|
||||
}
|
||||
|
||||
$this->ldapConnection = $ldapConnection;
|
||||
|
||||
return $this->ldapConnection;
|
||||
}
|
||||
|
||||
@@ -257,7 +225,6 @@ class LdapService
|
||||
// Otherwise, extract the port out
|
||||
$hostName = $serverNameParts[0];
|
||||
$ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
|
||||
|
||||
return ['host' => $hostName, 'port' => $ldapPort];
|
||||
}
|
||||
|
||||
@@ -271,15 +238,12 @@ class LdapService
|
||||
$newKey = '${' . $key . '}';
|
||||
$newAttrs[$newKey] = $this->ldap->escape($attrText);
|
||||
}
|
||||
|
||||
return strtr($filterString, $newAttrs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the groups a user is a part of on ldap.
|
||||
*
|
||||
* @throws LdapException
|
||||
* @throws JsonDebugException
|
||||
*/
|
||||
public function getUserGroups(string $userName): array
|
||||
{
|
||||
@@ -291,22 +255,12 @@ class LdapService
|
||||
}
|
||||
|
||||
$userGroups = $this->groupFilter($user);
|
||||
$allGroups = $this->getGroupsRecursive($userGroups, []);
|
||||
|
||||
if ($this->config['dump_user_groups']) {
|
||||
throw new JsonDebugException([
|
||||
'details_from_ldap' => $user,
|
||||
'parsed_direct_user_groups' => $userGroups,
|
||||
'parsed_recursive_user_groups' => $allGroups,
|
||||
]);
|
||||
}
|
||||
|
||||
return $allGroups;
|
||||
$userGroups = $this->getGroupsRecursive($userGroups, []);
|
||||
return $userGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent groups of an array of groups.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
private function getGroupsRecursive(array $groupsArray, array $checked): array
|
||||
@@ -333,7 +287,6 @@ class LdapService
|
||||
|
||||
/**
|
||||
* Get the parent groups of a single group.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
private function getGroupGroups(string $groupName): array
|
||||
@@ -367,7 +320,7 @@ class LdapService
|
||||
$count = 0;
|
||||
|
||||
if (isset($userGroupSearchResponse[$groupsAttr]['count'])) {
|
||||
$count = (int) $userGroupSearchResponse[$groupsAttr]['count'];
|
||||
$count = (int)$userGroupSearchResponse[$groupsAttr]['count'];
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
@@ -382,31 +335,11 @@ class LdapService
|
||||
|
||||
/**
|
||||
* Sync the LDAP groups to the user roles for the current user.
|
||||
*
|
||||
* @throws LdapException
|
||||
* @throws JsonDebugException
|
||||
*/
|
||||
public function syncGroups(User $user, string $username)
|
||||
{
|
||||
$userLdapGroups = $this->getUserGroups($username);
|
||||
$this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and attach an avatar image, if found in the ldap details, and attach
|
||||
* to the given user model.
|
||||
*/
|
||||
public function saveAndAttachAvatar(User $user, array $ldapUserDetails): void
|
||||
{
|
||||
if (is_null(config('services.ldap.thumbnail_attribute')) || is_null($ldapUserDetails['avatar'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$imageData = $ldapUserDetails['avatar'];
|
||||
$this->userAvatars->assignToUserFromExistingData($user, $imageData, 'jpg');
|
||||
} catch (\Exception $exception) {
|
||||
Log::info("Failed to use avatar image from LDAP data for user id {$user->id}");
|
||||
}
|
||||
$this->syncWithGroups($user, $userLdapGroups);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\Mfa\MfaSession;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Exception;
|
||||
|
||||
class LoginService
|
||||
{
|
||||
protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
|
||||
|
||||
protected $mfaSession;
|
||||
protected $emailConfirmationService;
|
||||
|
||||
public function __construct(MfaSession $mfaSession, EmailConfirmationService $emailConfirmationService)
|
||||
{
|
||||
$this->mfaSession = $mfaSession;
|
||||
$this->emailConfirmationService = $emailConfirmationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the given user into the system.
|
||||
* Will start a login of the given user but will prevent if there's
|
||||
* a reason to (MFA or Unconfirmed Email).
|
||||
* Returns a boolean to indicate the current login result.
|
||||
*
|
||||
* @throws StoppedAuthenticationException
|
||||
*/
|
||||
public function login(User $user, string $method, bool $remember = false): void
|
||||
{
|
||||
if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
|
||||
$this->setLastLoginAttemptedForUser($user, $method, $remember);
|
||||
|
||||
throw new StoppedAuthenticationException($user, $this);
|
||||
}
|
||||
|
||||
$this->clearLastLoginAttempted();
|
||||
auth()->login($user, $remember);
|
||||
Activity::add(ActivityType::AUTH_LOGIN, "{$method}; {$user->logDescriptor()}");
|
||||
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
|
||||
|
||||
// Authenticate on all session guards if a likely admin
|
||||
if ($user->can('users-manage') && $user->can('user-roles-manage')) {
|
||||
$guards = ['standard', 'ldap', 'saml2', 'oidc'];
|
||||
foreach ($guards as $guard) {
|
||||
auth($guard)->login($user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reattempt a system login after a previous stopped attempt.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function reattemptLoginFor(User $user)
|
||||
{
|
||||
if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
|
||||
throw new Exception('Login reattempt user does align with current session state');
|
||||
}
|
||||
|
||||
$lastLoginDetails = $this->getLastLoginAttemptDetails();
|
||||
$this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last user that was attempted to be logged in.
|
||||
* Only exists if the last login attempt had correct credentials
|
||||
* but had been prevented by a secondary factor.
|
||||
*/
|
||||
public function getLastLoginAttemptUser(): ?User
|
||||
{
|
||||
$id = $this->getLastLoginAttemptDetails()['user_id'];
|
||||
|
||||
return User::query()->where('id', '=', $id)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the details of the last login attempt.
|
||||
* Checks upon a ttl of about 1 hour since that last attempted login.
|
||||
*
|
||||
* @return array{user_id: ?string, method: ?string, remember: bool}
|
||||
*/
|
||||
protected function getLastLoginAttemptDetails(): array
|
||||
{
|
||||
$value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
|
||||
if (!$value) {
|
||||
return ['user_id' => null, 'method' => null];
|
||||
}
|
||||
|
||||
[$id, $method, $remember, $time] = explode(':', $value);
|
||||
$hourAgo = time() - (60 * 60);
|
||||
if ($time < $hourAgo) {
|
||||
$this->clearLastLoginAttempted();
|
||||
|
||||
return ['user_id' => null, 'method' => null];
|
||||
}
|
||||
|
||||
return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the last login attempted user.
|
||||
* Must be only used when credentials are correct and a login could be
|
||||
* achieved but a secondary factor has stopped the login.
|
||||
*/
|
||||
protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember)
|
||||
{
|
||||
session()->put(
|
||||
self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,
|
||||
implode(':', [$user->id, $method, $remember, time()])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the last login attempted session value.
|
||||
*/
|
||||
protected function clearLastLoginAttempted(): void
|
||||
{
|
||||
session()->remove(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MFA verification is needed.
|
||||
*/
|
||||
public function needsMfaVerification(User $user): bool
|
||||
{
|
||||
return !$this->mfaSession->isVerifiedForUser($user) && $this->mfaSession->isRequiredForUser($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given user is awaiting email confirmation.
|
||||
*/
|
||||
public function awaitingEmailConfirmation(User $user): bool
|
||||
{
|
||||
return $this->emailConfirmationService->confirmationRequired() && !$user->email_confirmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt the login of a user using the given credentials.
|
||||
* Meant to mirror Laravel's default guard 'attempt' method
|
||||
* but in a manner that always routes through our login system.
|
||||
* May interrupt the flow if extra authentication requirements are imposed.
|
||||
*
|
||||
* @throws StoppedAuthenticationException
|
||||
* @throws LoginAttemptException
|
||||
*/
|
||||
public function attempt(array $credentials, string $method, bool $remember = false): bool
|
||||
{
|
||||
$result = auth()->attempt($credentials, $remember);
|
||||
if ($result) {
|
||||
$user = auth()->user();
|
||||
auth()->logout();
|
||||
$this->login($user, $method, $remember);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Mfa;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BackupCodeService
|
||||
{
|
||||
/**
|
||||
* Generate a new set of 16 backup codes.
|
||||
*/
|
||||
public function generateNewSet(): array
|
||||
{
|
||||
$codes = [];
|
||||
while (count($codes) < 16) {
|
||||
$code = Str::random(5) . '-' . Str::random(5);
|
||||
if (!in_array($code, $codes)) {
|
||||
$codes[] = strtolower($code);
|
||||
}
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given code matches one of the available options.
|
||||
*/
|
||||
public function inputCodeExistsInSet(string $code, string $codeSet): bool
|
||||
{
|
||||
$cleanCode = $this->cleanInputCode($code);
|
||||
$codes = json_decode($codeSet);
|
||||
|
||||
return in_array($cleanCode, $codes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given input code from the given available options.
|
||||
* Will return a JSON string containing the codes.
|
||||
*/
|
||||
public function removeInputCodeFromSet(string $code, string $codeSet): string
|
||||
{
|
||||
$cleanCode = $this->cleanInputCode($code);
|
||||
$codes = json_decode($codeSet);
|
||||
$pos = array_search($cleanCode, $codes, true);
|
||||
array_splice($codes, $pos, 1);
|
||||
|
||||
return json_encode($codes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of codes in the given set.
|
||||
*/
|
||||
public function countCodesInSet(string $codeSet): int
|
||||
{
|
||||
return count(json_decode($codeSet));
|
||||
}
|
||||
|
||||
protected function cleanInputCode(string $code): string
|
||||
{
|
||||
return strtolower(str_replace(' ', '-', trim($code)));
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Mfa;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
|
||||
class MfaSession
|
||||
{
|
||||
/**
|
||||
* Check if MFA is required for the given user.
|
||||
*/
|
||||
public function isRequiredForUser(User $user): bool
|
||||
{
|
||||
// TODO - Test both these cases
|
||||
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given user is pending MFA setup.
|
||||
* (MFA required but not yet configured).
|
||||
*/
|
||||
public function isPendingMfaSetup(User $user): bool
|
||||
{
|
||||
return $this->isRequiredForUser($user) && !$user->mfaValues()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role of the given user enforces MFA.
|
||||
*/
|
||||
protected function userRoleEnforcesMfa(User $user): bool
|
||||
{
|
||||
return $user->roles()
|
||||
->where('mfa_enforced', '=', true)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current MFA session has already been verified for the given user.
|
||||
*/
|
||||
public function isVerifiedForUser(User $user): bool
|
||||
{
|
||||
return session()->get($this->getMfaVerifiedSessionKey($user)) === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the current session as MFA-verified.
|
||||
*/
|
||||
public function markVerifiedForUser(User $user): void
|
||||
{
|
||||
session()->put($this->getMfaVerifiedSessionKey($user), 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session key in which the MFA verification status is stored.
|
||||
*/
|
||||
protected function getMfaVerifiedSessionKey(User $user): string
|
||||
{
|
||||
return 'mfa-verification-passed:' . $user->id;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Mfa;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $method
|
||||
* @property string $value
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class MfaValue extends Model
|
||||
{
|
||||
protected static $unguarded = true;
|
||||
|
||||
const METHOD_TOTP = 'totp';
|
||||
const METHOD_BACKUP_CODES = 'backup_codes';
|
||||
|
||||
/**
|
||||
* Get all the MFA methods available.
|
||||
*/
|
||||
public static function allMethods(): array
|
||||
{
|
||||
return [self::METHOD_TOTP, self::METHOD_BACKUP_CODES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a new MFA value for the given user and method
|
||||
* using the provided value.
|
||||
*/
|
||||
public static function upsertWithValue(User $user, string $method, string $value): void
|
||||
{
|
||||
/** @var MfaValue $mfaVal */
|
||||
$mfaVal = static::query()->firstOrNew([
|
||||
'user_id' => $user->id,
|
||||
'method' => $method,
|
||||
]);
|
||||
$mfaVal->setValue($value);
|
||||
$mfaVal->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Easily get the decrypted MFA value for the given user and method.
|
||||
*/
|
||||
public static function getValueForUser(User $user, string $method): ?string
|
||||
{
|
||||
/** @var MfaValue $mfaVal */
|
||||
$mfaVal = static::query()
|
||||
->where('user_id', '=', $user->id)
|
||||
->where('method', '=', $method)
|
||||
->first();
|
||||
|
||||
return $mfaVal ? $mfaVal->getValue() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the value attribute upon access.
|
||||
*/
|
||||
protected function getValue(): string
|
||||
{
|
||||
return decrypt($this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the value attribute upon access.
|
||||
*/
|
||||
protected function setValue($value): void
|
||||
{
|
||||
$this->value = encrypt($value);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Mfa;
|
||||
|
||||
use BaconQrCode\Renderer\Color\Rgb;
|
||||
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
||||
use BaconQrCode\Renderer\ImageRenderer;
|
||||
use BaconQrCode\Renderer\RendererStyle\Fill;
|
||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||
use BaconQrCode\Writer;
|
||||
use BookStack\Auth\User;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use PragmaRX\Google2FA\Support\Constants;
|
||||
|
||||
class TotpService
|
||||
{
|
||||
protected $google2fa;
|
||||
|
||||
public function __construct(Google2FA $google2fa)
|
||||
{
|
||||
$this->google2fa = $google2fa;
|
||||
// Use SHA1 as a default, Personal testing of other options in 2021 found
|
||||
// many apps lack support for other algorithms yet still will scan
|
||||
// the code causing a confusing UX.
|
||||
$this->google2fa->setAlgorithm(Constants::SHA1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new totp secret key.
|
||||
*/
|
||||
public function generateSecret(): string
|
||||
{
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
return $this->google2fa->generateSecretKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a TOTP URL from secret key.
|
||||
*/
|
||||
public function generateUrl(string $secret, User $user): string
|
||||
{
|
||||
return $this->google2fa->getQRCodeUrl(
|
||||
setting('app-name'),
|
||||
$user->email,
|
||||
$secret
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code to display a TOTP URL.
|
||||
*/
|
||||
public function generateQrCodeSvg(string $url): string
|
||||
{
|
||||
$color = Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(32, 110, 167));
|
||||
|
||||
return (new Writer(
|
||||
new ImageRenderer(
|
||||
new RendererStyle(192, 4, null, null, $color),
|
||||
new SvgImageBackEnd()
|
||||
)
|
||||
))->writeString($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the user provided code is valid for the secret.
|
||||
* The secret must be known, not user-provided.
|
||||
*/
|
||||
public function verifyCode(string $code, string $secret): bool
|
||||
{
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
return $this->google2fa->verifyKey($secret, $code);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Mfa;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
|
||||
class TotpValidationRule implements Rule
|
||||
{
|
||||
protected $secret;
|
||||
protected $totpService;
|
||||
|
||||
/**
|
||||
* Create a new rule instance.
|
||||
* Takes the TOTP secret that must be system provided, not user provided.
|
||||
*/
|
||||
public function __construct(string $secret)
|
||||
{
|
||||
$this->secret = $secret;
|
||||
$this->totpService = app()->make(TotpService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the validation rule passes.
|
||||
*/
|
||||
public function passes($attribute, $value)
|
||||
{
|
||||
return $this->totpService->verifyCode($value, $this->secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation error message.
|
||||
*/
|
||||
public function message()
|
||||
{
|
||||
return trans('validation.totp');
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\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\Auth\Access\Oidc;
|
||||
|
||||
use Exception;
|
||||
|
||||
class OidcException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
class OidcIdToken
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $header;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $payload;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature;
|
||||
|
||||
/**
|
||||
* @var array[]|string[]
|
||||
*/
|
||||
protected $keys;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $issuer;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $tokenParts = [];
|
||||
|
||||
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 all possible parts of the id token.
|
||||
*
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
public function validate(string $clientId): bool
|
||||
{
|
||||
$this->validateTokenStructure();
|
||||
$this->validateTokenSignature();
|
||||
$this->validateTokenClaims($clientId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific claim from this token.
|
||||
* Returns null if it is null or does not exist.
|
||||
*
|
||||
* @return mixed|null
|
||||
*/
|
||||
public function getClaim(string $claim)
|
||||
{
|
||||
return $this->payload[$claim] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all returned claims within the token.
|
||||
*/
|
||||
public function getAllClaims(): array
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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.
|
||||
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, or if it contains additional
|
||||
// audiences not trusted by the Client.
|
||||
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 (count($aud) !== 1) {
|
||||
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
|
||||
}
|
||||
|
||||
if ($aud[0] !== $clientId) {
|
||||
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
|
||||
}
|
||||
|
||||
// 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\Auth\Access\Oidc;
|
||||
|
||||
class OidcInvalidKeyException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
use Exception;
|
||||
|
||||
class OidcInvalidTokenException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
use Exception;
|
||||
|
||||
class OidcIssuerDiscoveryException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\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,141 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\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;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $authorizationEndpoint;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $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 an additional scope to this provider upon the default.
|
||||
*/
|
||||
public function addScope(string $scope): void
|
||||
{
|
||||
$this->scopes[] = $scope;
|
||||
$this->scopes = array_unique($this->scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default scopes used by this provider.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @param ResponseInterface $response
|
||||
* @param array|string $data Parsed response data
|
||||
*
|
||||
* @throws IdentityProviderException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function checkResponse(ResponseInterface $response, $data)
|
||||
{
|
||||
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.
|
||||
*
|
||||
* @param array $response
|
||||
* @param AccessToken $token
|
||||
*
|
||||
* @return ResourceOwnerInterface
|
||||
*/
|
||||
protected function createResourceOwner(array $response, AccessToken $token)
|
||||
{
|
||||
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.
|
||||
*
|
||||
* @param array $response
|
||||
* @param AbstractGrant $grant
|
||||
*
|
||||
* @return OidcAccessToken
|
||||
*/
|
||||
protected function createAccessToken(array $response, AbstractGrant $grant)
|
||||
{
|
||||
return new OidcAccessToken($response);
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\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 $redirectUri;
|
||||
public ?string $authorizationEndpoint;
|
||||
public ?string $tokenEndpoint;
|
||||
|
||||
/**
|
||||
* @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)
|
||||
{
|
||||
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()
|
||||
{
|
||||
$required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
|
||||
foreach ($required as $prop) {
|
||||
if (empty($this->$prop)) {
|
||||
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
|
||||
}
|
||||
}
|
||||
|
||||
if (strpos($this->issuer, 'https://') !== 0) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and autoload settings from the configured issuer.
|
||||
*
|
||||
* @throws OidcIssuerDiscoveryException
|
||||
*/
|
||||
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
|
||||
{
|
||||
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['jwks_uri'])) {
|
||||
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
|
||||
$discoveredSettings['keys'] = $this->filterKeys($keys);
|
||||
}
|
||||
|
||||
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 arrayForProvider(): array
|
||||
{
|
||||
$settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
|
||||
$settings = [];
|
||||
foreach ($settingKeys as $setting) {
|
||||
$settings[$setting] = $this->$setting;
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
use BookStack\Auth\Access\GroupSyncService;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
||||
use Psr\Http\Client\ClientInterface as HttpClient;
|
||||
|
||||
/**
|
||||
* Class OpenIdConnectService
|
||||
* Handles any app-specific OIDC tasks.
|
||||
*/
|
||||
class OidcService
|
||||
{
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
protected HttpClient $httpClient;
|
||||
protected GroupSyncService $groupService;
|
||||
|
||||
/**
|
||||
* OpenIdService constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService,
|
||||
HttpClient $httpClient,
|
||||
GroupSyncService $groupService
|
||||
) {
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
$this->httpClient = $httpClient;
|
||||
$this->groupService = $groupService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate an authorization flow.
|
||||
*
|
||||
* @throws OidcException
|
||||
*
|
||||
* @return array{url: string, state: string}
|
||||
*/
|
||||
public function login(): array
|
||||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
return [
|
||||
'url' => $provider->getAuthorizationUrl(),
|
||||
'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);
|
||||
|
||||
// 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'],
|
||||
'redirectUri' => url('/oidc/callback'),
|
||||
'authorizationEndpoint' => $config['authorization_endpoint'],
|
||||
'tokenEndpoint' => $config['token_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->httpClient, Cache::store(null), 15);
|
||||
} catch (OidcIssuerDiscoveryException $exception) {
|
||||
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$settings->validate();
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the underlying OpenID Connect Provider.
|
||||
*/
|
||||
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
|
||||
{
|
||||
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
|
||||
'httpClient' => $this->httpClient,
|
||||
'optionProvider' => new HttpBasicAuthOptionProvider(),
|
||||
]);
|
||||
|
||||
foreach ($this->getAdditionalScopes() as $scope) {
|
||||
$provider->addScope($scope);
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get any user-defined addition/custom scopes to apply to the authentication request.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
protected function getAdditionalScopes(): array
|
||||
{
|
||||
$scopeConfig = $this->config()['additional_scopes'] ?: '';
|
||||
|
||||
$scopeArr = explode(',', $scopeConfig);
|
||||
$scopeArr = array_map(fn (string $scope) => trim($scope), $scopeArr);
|
||||
|
||||
return array_filter($scopeArr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the display name.
|
||||
*/
|
||||
protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
|
||||
{
|
||||
$displayNameAttr = $this->config()['display_name_claims'];
|
||||
|
||||
$displayName = [];
|
||||
foreach ($displayNameAttr as $dnAttr) {
|
||||
$dnComponent = $token->getClaim($dnAttr) ?? '';
|
||||
if ($dnComponent !== '') {
|
||||
$displayName[] = $dnComponent;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($displayName) == 0) {
|
||||
$displayName[] = $defaultValue;
|
||||
}
|
||||
|
||||
return implode(' ', $displayName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the assigned groups from the id token.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
protected function getUserGroups(OidcIdToken $token): array
|
||||
{
|
||||
$groupsAttr = $this->config()['groups_claim'];
|
||||
if (empty($groupsAttr)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
|
||||
if (!is_array($groupsList)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($groupsList, function ($val) {
|
||||
return is_string($val);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the details of a user from an ID token.
|
||||
*
|
||||
* @return array{name: string, email: string, external_id: string, groups: string[]}
|
||||
*/
|
||||
protected function getUserDetails(OidcIdToken $token): array
|
||||
{
|
||||
$id = $token->getClaim('sub');
|
||||
|
||||
return [
|
||||
'external_id' => $id,
|
||||
'email' => $token->getClaim('email'),
|
||||
'name' => $this->getUserDisplayName($token, $id),
|
||||
'groups' => $this->getUserGroups($token),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
|
||||
if ($this->config()['dump_user_details']) {
|
||||
throw new JsonDebugException($idToken->getAllClaims());
|
||||
}
|
||||
|
||||
try {
|
||||
$idToken->validate($settings->clientId);
|
||||
} catch (OidcInvalidTokenException $exception) {
|
||||
throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
|
||||
}
|
||||
|
||||
$userDetails = $this->getUserDetails($idToken);
|
||||
$isLoggedIn = auth()->check();
|
||||
|
||||
if (empty($userDetails['email'])) {
|
||||
throw new OidcException(trans('errors.oidc_no_email_address'));
|
||||
}
|
||||
|
||||
if ($isLoggedIn) {
|
||||
throw new OidcException(trans('errors.oidc_already_logged_in'));
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $this->registrationService->findOrRegister(
|
||||
$userDetails['name'],
|
||||
$userDetails['email'],
|
||||
$userDetails['external_id']
|
||||
);
|
||||
} catch (UserRegistrationException $exception) {
|
||||
throw new OidcException($exception->getMessage());
|
||||
}
|
||||
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$groups = $userDetails['groups'];
|
||||
$detachExisting = $this->config()['remove_from_groups'];
|
||||
$this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
|
||||
}
|
||||
|
||||
$this->loginService->login($user, 'oidc');
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,14 @@
|
||||
<?php
|
||||
<?php namespace BookStack\Auth\Access;
|
||||
|
||||
namespace BookStack\Auth\Access;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\SocialAccount;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Exception;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RegistrationService
|
||||
{
|
||||
|
||||
protected $userRepo;
|
||||
protected $emailConfirmationService;
|
||||
|
||||
@@ -29,7 +23,6 @@ class RegistrationService
|
||||
|
||||
/**
|
||||
* Check whether or not registrations are allowed in the app settings.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
public function ensureRegistrationAllowed()
|
||||
@@ -47,39 +40,11 @@ class RegistrationService
|
||||
{
|
||||
$authMethod = config('auth.method');
|
||||
$authMethodsWithRegistration = ['standard'];
|
||||
|
||||
return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to find a user in the system otherwise register them as a new
|
||||
* user. For use with external auth systems since password is auto-generated.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
public function findOrRegister(string $name, string $email, string $externalId): User
|
||||
{
|
||||
$user = User::query()
|
||||
->where('external_auth_id', '=', $externalId)
|
||||
->first();
|
||||
|
||||
if (is_null($user)) {
|
||||
$userData = [
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => Str::random(32),
|
||||
'external_auth_id' => $externalId,
|
||||
];
|
||||
|
||||
$user = $this->registerUser($userData, null, false);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* The registrations flow for all users.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User
|
||||
@@ -92,33 +57,29 @@ class RegistrationService
|
||||
// Ensure user does not already exist
|
||||
$alreadyUser = !is_null($this->userRepo->getByEmail($userEmail));
|
||||
if ($alreadyUser) {
|
||||
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
|
||||
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]));
|
||||
}
|
||||
|
||||
// Create the user
|
||||
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
|
||||
$newUser->attachDefaultRole();
|
||||
$newUser = $this->userRepo->registerNew($userData, $emailConfirmed);
|
||||
|
||||
// Assign social account if given
|
||||
if ($socialAccount) {
|
||||
$newUser->socialAccounts()->save($socialAccount);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
|
||||
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver(), $newUser);
|
||||
|
||||
// Start email confirmation flow if required
|
||||
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
|
||||
$newUser->save();
|
||||
$message = '';
|
||||
|
||||
try {
|
||||
$this->emailConfirmationService->sendConfirmation($newUser);
|
||||
session()->flash('sent-email-confirmation', true);
|
||||
} catch (Exception $e) {
|
||||
$message = trans('auth.email_confirm_send_error');
|
||||
|
||||
throw new UserRegistrationException($message, '/register/confirm');
|
||||
}
|
||||
|
||||
throw new UserRegistrationException($message, '/register/confirm');
|
||||
}
|
||||
|
||||
return $newUser;
|
||||
@@ -127,7 +88,6 @@ class RegistrationService
|
||||
/**
|
||||
* Ensure that the given email meets any active email domain registration restrictions.
|
||||
* Throws if restrictions are active and the email does not match an allowed domain.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
protected function ensureEmailDomainAllowed(string $userEmail): void
|
||||
@@ -139,11 +99,20 @@ class RegistrationService
|
||||
}
|
||||
|
||||
$restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
|
||||
$userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, '@'), 1);
|
||||
$userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, "@"), 1);
|
||||
if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
|
||||
$redirect = $this->registrationAllowed() ? '/register' : '/login';
|
||||
|
||||
throw new UserRegistrationException(trans('auth.registration_email_domain_invalid'), $redirect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias to the UserRepo method of the same name.
|
||||
* Attaches the default system role, if configured, to the given user.
|
||||
*/
|
||||
public function attachDefaultRole(User $user): void
|
||||
{
|
||||
$this->userRepo->attachDefaultRole($user);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access;
|
||||
<?php namespace BookStack\Auth\Access;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\SamlException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use Exception;
|
||||
use Illuminate\Support\Str;
|
||||
use OneLogin\Saml2\Auth;
|
||||
use OneLogin\Saml2\Constants;
|
||||
use OneLogin\Saml2\Error;
|
||||
use OneLogin\Saml2\IdPMetadataParser;
|
||||
use OneLogin\Saml2\ValidationError;
|
||||
@@ -18,59 +15,47 @@ use OneLogin\Saml2\ValidationError;
|
||||
* Class Saml2Service
|
||||
* Handles any app-specific SAML tasks.
|
||||
*/
|
||||
class Saml2Service
|
||||
class Saml2Service extends ExternalAuthService
|
||||
{
|
||||
protected array $config;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
protected GroupSyncService $groupSyncService;
|
||||
protected $config;
|
||||
protected $registrationService;
|
||||
protected $user;
|
||||
|
||||
public function __construct(
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService,
|
||||
GroupSyncService $groupSyncService
|
||||
) {
|
||||
/**
|
||||
* Saml2Service constructor.
|
||||
*/
|
||||
public function __construct(RegistrationService $registrationService, User $user)
|
||||
{
|
||||
$this->config = config('saml2');
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
$this->groupSyncService = $groupSyncService;
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a login flow.
|
||||
*
|
||||
* @throws Error
|
||||
*/
|
||||
public function login(): array
|
||||
{
|
||||
$toolKit = $this->getToolkit();
|
||||
$returnRoute = url('/saml2/acs');
|
||||
|
||||
return [
|
||||
'url' => $toolKit->login($returnRoute, [], false, false, true),
|
||||
'id' => $toolKit->getLastRequestID(),
|
||||
'id' => $toolKit->getLastRequestID(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a logout flow.
|
||||
*
|
||||
* @throws Error
|
||||
*/
|
||||
public function logout(User $user): array
|
||||
public function logout(): array
|
||||
{
|
||||
$toolKit = $this->getToolkit();
|
||||
$returnRoute = url('/');
|
||||
|
||||
try {
|
||||
$url = $toolKit->logout(
|
||||
$returnRoute,
|
||||
[],
|
||||
$user->email,
|
||||
null,
|
||||
true,
|
||||
Constants::NAMEID_EMAIL_ADDRESS
|
||||
);
|
||||
$url = $toolKit->logout($returnRoute, [], null, null, true);
|
||||
$id = $toolKit->getLastRequestID();
|
||||
} catch (Error $error) {
|
||||
if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) {
|
||||
@@ -89,27 +74,22 @@ class Saml2Service
|
||||
* Process the ACS response from the idp and return the
|
||||
* matching, or new if registration active, user matched to the idp.
|
||||
* Returns null if not authenticated.
|
||||
*
|
||||
* @throws Error
|
||||
* @throws SamlException
|
||||
* @throws ValidationError
|
||||
* @throws JsonDebugException
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
public function processAcsResponse(?string $requestId, string $samlResponse): ?User
|
||||
public function processAcsResponse(?string $requestId): ?User
|
||||
{
|
||||
// The SAML2 toolkit expects the response to be within the $_POST superglobal
|
||||
// so we need to manually put it back there at this point.
|
||||
$_POST['SAMLResponse'] = $samlResponse;
|
||||
$toolkit = $this->getToolkit();
|
||||
$toolkit->processResponse($requestId);
|
||||
$errors = $toolkit->getErrors();
|
||||
|
||||
if (!empty($errors)) {
|
||||
$reason = $toolkit->getLastErrorReason();
|
||||
$message = 'Invalid ACS Response; Errors: ' . implode(', ', $errors);
|
||||
$message .= $reason ? "; Reason: {$reason}" : '';
|
||||
throw new Error($message);
|
||||
throw new Error(
|
||||
'Invalid ACS Response: '.implode(', ', $errors)
|
||||
);
|
||||
}
|
||||
|
||||
if (!$toolkit->isAuthenticated()) {
|
||||
@@ -124,29 +104,22 @@ class Saml2Service
|
||||
|
||||
/**
|
||||
* Process a response for the single logout service.
|
||||
*
|
||||
* @throws Error
|
||||
*/
|
||||
public function processSlsResponse(?string $requestId): ?string
|
||||
{
|
||||
$toolkit = $this->getToolkit();
|
||||
$redirect = $toolkit->processSLO(true, $requestId, false, null, true);
|
||||
|
||||
// The $retrieveParametersFromServer in the call below will mean the library will take the query
|
||||
// parameters, used for the response signing, from the raw $_SERVER['QUERY_STRING']
|
||||
// value so that the exact encoding format is matched when checking the signature.
|
||||
// This is primarily due to ADFS encoding query params with lowercase percent encoding while
|
||||
// PHP (And most other sensible providers) standardise on uppercase.
|
||||
$redirect = $toolkit->processSLO(true, $requestId, true, null, true);
|
||||
$errors = $toolkit->getErrors();
|
||||
|
||||
if (!empty($errors)) {
|
||||
throw new Error(
|
||||
'Invalid SLS Response: ' . implode(', ', $errors)
|
||||
'Invalid SLS Response: '.implode(', ', $errors)
|
||||
);
|
||||
}
|
||||
|
||||
$this->actionLogout();
|
||||
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
@@ -161,19 +134,18 @@ class Saml2Service
|
||||
|
||||
/**
|
||||
* Get the metadata for this service provider.
|
||||
*
|
||||
* @throws Error
|
||||
*/
|
||||
public function metadata(): string
|
||||
{
|
||||
$toolKit = $this->getToolkit(true);
|
||||
$toolKit = $this->getToolkit();
|
||||
$settings = $toolKit->getSettings();
|
||||
$metadata = $settings->getSPMetadata();
|
||||
$errors = $settings->validateMetadata($metadata);
|
||||
|
||||
if (!empty($errors)) {
|
||||
throw new Error(
|
||||
'Invalid SP metadata: ' . implode(', ', $errors),
|
||||
'Invalid SP metadata: '.implode(', ', $errors),
|
||||
Error::METADATA_SP_INVALID
|
||||
);
|
||||
}
|
||||
@@ -183,11 +155,10 @@ class Saml2Service
|
||||
|
||||
/**
|
||||
* Load the underlying Onelogin SAML2 toolkit.
|
||||
*
|
||||
* @throws Error
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function getToolkit(bool $spOnly = false): Auth
|
||||
protected function getToolkit(): Auth
|
||||
{
|
||||
$settings = $this->config['onelogin'];
|
||||
$overrides = $this->config['onelogin_overrides'] ?? [];
|
||||
@@ -197,14 +168,13 @@ class Saml2Service
|
||||
}
|
||||
|
||||
$metaDataSettings = [];
|
||||
if (!$spOnly && $this->config['autoload_from_metadata']) {
|
||||
if ($this->config['autoload_from_metadata']) {
|
||||
$metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']);
|
||||
}
|
||||
|
||||
$spSettings = $this->loadOneloginServiceProviderDetails();
|
||||
$settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
|
||||
|
||||
return new Auth($settings, $spOnly);
|
||||
return new Auth($settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,18 +183,18 @@ class Saml2Service
|
||||
protected function loadOneloginServiceProviderDetails(): array
|
||||
{
|
||||
$spDetails = [
|
||||
'entityId' => url('/saml2/metadata'),
|
||||
'entityId' => url('/saml2/metadata'),
|
||||
'assertionConsumerService' => [
|
||||
'url' => url('/saml2/acs'),
|
||||
],
|
||||
'singleLogoutService' => [
|
||||
'url' => url('/saml2/sls'),
|
||||
'url' => url('/saml2/sls')
|
||||
],
|
||||
];
|
||||
|
||||
return [
|
||||
'baseurl' => url('/saml2'),
|
||||
'sp' => $spDetails,
|
||||
'sp' => $spDetails
|
||||
];
|
||||
}
|
||||
|
||||
@@ -237,7 +207,7 @@ class Saml2Service
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the display name.
|
||||
* Calculate the display name
|
||||
*/
|
||||
protected function getUserDisplayName(array $samlAttributes, string $defaultValue): string
|
||||
{
|
||||
@@ -276,8 +246,6 @@ class Saml2Service
|
||||
|
||||
/**
|
||||
* Extract the details of a user from a SAML response.
|
||||
*
|
||||
* @return array{external_id: string, name: string, email: string, saml_id: string}
|
||||
*/
|
||||
protected function getUserDetails(string $samlID, $samlAttributes): array
|
||||
{
|
||||
@@ -289,9 +257,9 @@ class Saml2Service
|
||||
|
||||
return [
|
||||
'external_id' => $externalId,
|
||||
'name' => $this->getUserDisplayName($samlAttributes, $externalId),
|
||||
'email' => $email,
|
||||
'saml_id' => $samlID,
|
||||
'name' => $this->getUserDisplayName($samlAttributes, $externalId),
|
||||
'email' => $email,
|
||||
'saml_id' => $samlID,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -325,7 +293,6 @@ class Saml2Service
|
||||
$data = $data[0];
|
||||
break;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -342,14 +309,37 @@ class Saml2Service
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user from the database for the specified details.
|
||||
* @throws SamlException
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
protected function getOrRegisterUser(array $userDetails): ?User
|
||||
{
|
||||
$user = $this->user->newQuery()
|
||||
->where('external_auth_id', '=', $userDetails['external_id'])
|
||||
->first();
|
||||
|
||||
if (is_null($user)) {
|
||||
$userData = [
|
||||
'name' => $userDetails['name'],
|
||||
'email' => $userDetails['email'],
|
||||
'password' => Str::random(32),
|
||||
'external_auth_id' => $userDetails['external_id'],
|
||||
];
|
||||
|
||||
$user = $this->registrationService->registerUser($userData, null, false);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the SAML response for a user. Login the user when
|
||||
* they exist, optionally registering them automatically.
|
||||
*
|
||||
* @throws SamlException
|
||||
* @throws JsonDebugException
|
||||
* @throws UserRegistrationException
|
||||
* @throws StoppedAuthenticationException
|
||||
*/
|
||||
public function processLoginCallback(string $samlID, array $samlAttributes): User
|
||||
{
|
||||
@@ -358,8 +348,8 @@ class Saml2Service
|
||||
|
||||
if ($this->config['dump_user_details']) {
|
||||
throw new JsonDebugException([
|
||||
'id_from_idp' => $samlID,
|
||||
'attrs_from_idp' => $samlAttributes,
|
||||
'id_from_idp' => $samlID,
|
||||
'attrs_from_idp' => $samlAttributes,
|
||||
'attrs_after_parsing' => $userDetails,
|
||||
]);
|
||||
}
|
||||
@@ -372,23 +362,17 @@ class Saml2Service
|
||||
throw new SamlException(trans('errors.saml_already_logged_in'), '/login');
|
||||
}
|
||||
|
||||
$user = $this->registrationService->findOrRegister(
|
||||
$userDetails['name'],
|
||||
$userDetails['email'],
|
||||
$userDetails['external_id']
|
||||
);
|
||||
|
||||
$user = $this->getOrRegisterUser($userDetails);
|
||||
if ($user === null) {
|
||||
throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
|
||||
}
|
||||
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$groups = $this->getUserGroups($samlAttributes);
|
||||
$this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']);
|
||||
$this->syncWithGroups($user, $groups);
|
||||
}
|
||||
|
||||
$this->loginService->login($user, 'saml2');
|
||||
|
||||
auth()->login($user);
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access;
|
||||
<?php namespace BookStack\Auth\Access;
|
||||
|
||||
use BookStack\Auth\SocialAccount;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Exceptions\SocialDriverNotConfigured;
|
||||
use BookStack\Exceptions\SocialSignInAccountNotUsed;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\Factory as Socialite;
|
||||
use Laravel\Socialite\Contracts\Provider;
|
||||
use Laravel\Socialite\Contracts\User as SocialUser;
|
||||
use Laravel\Socialite\Two\GoogleProvider;
|
||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
class SocialAuthService
|
||||
{
|
||||
/**
|
||||
* The core socialite library used.
|
||||
*
|
||||
* @var Socialite
|
||||
*/
|
||||
|
||||
protected $userRepo;
|
||||
protected $socialite;
|
||||
protected $socialAccount;
|
||||
|
||||
/**
|
||||
* @var LoginService
|
||||
*/
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* The default built-in social drivers we support.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $validSocialDrivers = [
|
||||
'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 $configureForRedirectCallbacks = [];
|
||||
protected $validSocialDrivers = ['google', 'github', 'facebook', 'slack', 'twitter', 'azure', 'okta', 'gitlab', 'twitch', 'discord'];
|
||||
|
||||
/**
|
||||
* SocialAuthService constructor.
|
||||
*/
|
||||
public function __construct(Socialite $socialite, LoginService $loginService)
|
||||
public function __construct(UserRepo $userRepo, Socialite $socialite, SocialAccount $socialAccount)
|
||||
{
|
||||
$this->userRepo = $userRepo;
|
||||
$this->socialite = $socialite;
|
||||
$this->loginService = $loginService;
|
||||
$this->socialAccount = $socialAccount;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Start the social login path.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
*/
|
||||
public function startLogIn(string $socialDriver): RedirectResponse
|
||||
{
|
||||
$driver = $this->validateDriver($socialDriver);
|
||||
|
||||
return $this->getDriverForRedirect($driver)->redirect();
|
||||
return $this->getSocialDriver($driver)->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the social registration process.
|
||||
*
|
||||
* Start the social registration process
|
||||
* @throws SocialDriverNotConfigured
|
||||
*/
|
||||
public function startRegister(string $socialDriver): RedirectResponse
|
||||
{
|
||||
$driver = $this->validateDriver($socialDriver);
|
||||
|
||||
return $this->getDriverForRedirect($driver)->redirect();
|
||||
return $this->getSocialDriver($driver)->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the social registration process on callback.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser): SocialUser
|
||||
{
|
||||
// Check social account has not already been used
|
||||
if (SocialAccount::query()->where('driver_id', '=', $socialUser->getId())->exists()) {
|
||||
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount' => $socialDriver]), '/login');
|
||||
if ($this->socialAccount->where('driver_id', '=', $socialUser->getId())->exists()) {
|
||||
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount'=>$socialDriver]), '/login');
|
||||
}
|
||||
|
||||
if (User::query()->where('email', '=', $socialUser->getEmail())->exists()) {
|
||||
if ($this->userRepo->getByEmail($socialUser->getEmail())) {
|
||||
$email = $socialUser->getEmail();
|
||||
|
||||
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $email]), '/login');
|
||||
}
|
||||
|
||||
@@ -114,19 +72,16 @@ class SocialAuthService
|
||||
|
||||
/**
|
||||
* Get the social user details via the social driver.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
*/
|
||||
public function getSocialUser(string $socialDriver): SocialUser
|
||||
{
|
||||
$driver = $this->validateDriver($socialDriver);
|
||||
|
||||
return $this->socialite->driver($driver)->user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the login process on a oAuth callback.
|
||||
*
|
||||
* @throws SocialSignInAccountNotUsed
|
||||
*/
|
||||
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
|
||||
@@ -134,7 +89,7 @@ class SocialAuthService
|
||||
$socialId = $socialUser->getId();
|
||||
|
||||
// Get any attached social accounts or users
|
||||
$socialAccount = SocialAccount::query()->where('driver_id', '=', $socialId)->first();
|
||||
$socialAccount = $this->socialAccount->where('driver_id', '=', $socialId)->first();
|
||||
$isLoggedIn = auth()->check();
|
||||
$currentUser = user();
|
||||
$titleCaseDriver = Str::title($socialDriver);
|
||||
@@ -142,32 +97,28 @@ class SocialAuthService
|
||||
// When a user is not logged in and a matching SocialAccount exists,
|
||||
// Simply log the user into the application.
|
||||
if (!$isLoggedIn && $socialAccount !== null) {
|
||||
$this->loginService->login($socialAccount->user, $socialDriver);
|
||||
|
||||
auth()->login($socialAccount->user);
|
||||
return redirect()->intended('/');
|
||||
}
|
||||
|
||||
// When a user is logged in but the social account does not exist,
|
||||
// Create the social account and attach it to the user & redirect to the profile page.
|
||||
if ($isLoggedIn && $socialAccount === null) {
|
||||
$account = $this->newSocialAccount($socialDriver, $socialUser);
|
||||
$currentUser->socialAccounts()->save($account);
|
||||
$this->fillSocialAccount($socialDriver, $socialUser);
|
||||
$currentUser->socialAccounts()->save($this->socialAccount);
|
||||
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver]));
|
||||
|
||||
return redirect($currentUser->getEditUrl());
|
||||
}
|
||||
|
||||
// When a user is logged in and the social account exists and is already linked to the current user.
|
||||
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
|
||||
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver]));
|
||||
|
||||
return redirect($currentUser->getEditUrl());
|
||||
}
|
||||
|
||||
// When a user is logged in, A social account exists but the users do not match.
|
||||
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
|
||||
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver]));
|
||||
|
||||
return redirect($currentUser->getEditUrl());
|
||||
}
|
||||
|
||||
@@ -176,13 +127,12 @@ class SocialAuthService
|
||||
if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') {
|
||||
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]);
|
||||
}
|
||||
|
||||
|
||||
throw new SocialSignInAccountNotUsed($message, '/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the social driver is correct and supported.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
*/
|
||||
protected function validateDriver(string $socialDriver): string
|
||||
@@ -208,7 +158,6 @@ class SocialAuthService
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -255,61 +204,36 @@ class SocialAuthService
|
||||
/**
|
||||
* Fill and return a SocialAccount from the given driver name and SocialUser.
|
||||
*/
|
||||
public function newSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
|
||||
public function fillSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
|
||||
{
|
||||
return new SocialAccount([
|
||||
$this->socialAccount->fill([
|
||||
'driver' => $socialDriver,
|
||||
'driver_id' => $socialUser->getId(),
|
||||
'avatar' => $socialUser->getAvatar(),
|
||||
'avatar' => $socialUser->getAvatar()
|
||||
]);
|
||||
return $this->socialAccount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach a social account from a user.
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
||||
*/
|
||||
public function detachSocialAccount(string $socialDriver): void
|
||||
public function detachSocialAccount(string $socialDriver)
|
||||
{
|
||||
user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide redirect options per service for the Laravel Socialite driver.
|
||||
* Provide redirect options per service for the Laravel Socialite driver
|
||||
*/
|
||||
protected function getDriverForRedirect(string $driverName): Provider
|
||||
public function getSocialDriver(string $driverName): Provider
|
||||
{
|
||||
$driver = $this->socialite->driver($driverName);
|
||||
|
||||
if ($driver instanceof GoogleProvider && config('services.google.select_account')) {
|
||||
if ($driverName === 'google' && config('services.google.select_account')) {
|
||||
$driver->with(['prompt' => 'select_account']);
|
||||
}
|
||||
|
||||
if (isset($this->configureForRedirectCallbacks[$driverName])) {
|
||||
$this->configureForRedirectCallbacks[$driverName]($driver);
|
||||
}
|
||||
|
||||
return $driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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->validSocialDrivers[] = $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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access;
|
||||
<?php namespace BookStack\Auth\Access;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Notifications\UserInvite;
|
||||
@@ -13,7 +11,6 @@ class UserInviteService extends UserTokenService
|
||||
/**
|
||||
* Send an invitation to a user to sign into BookStack
|
||||
* Removes existing invitation tokens.
|
||||
*
|
||||
* @param User $user
|
||||
*/
|
||||
public function sendInvitation(User $user)
|
||||
|
||||
@@ -1,56 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access;
|
||||
<?php namespace BookStack\Auth\Access;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\UserTokenExpiredException;
|
||||
use BookStack\Exceptions\UserTokenNotFoundException;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\Connection as Database;
|
||||
use Illuminate\Support\Str;
|
||||
use stdClass;
|
||||
|
||||
class UserTokenService
|
||||
{
|
||||
|
||||
/**
|
||||
* Name of table where user tokens are stored.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $tokenTable = 'user_tokens';
|
||||
|
||||
/**
|
||||
* Token expiry time in hours.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $expiryTime = 24;
|
||||
|
||||
protected $db;
|
||||
|
||||
/**
|
||||
* UserTokenService constructor.
|
||||
* @param Database $db
|
||||
*/
|
||||
public function __construct(Database $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all email confirmations that belong to a user.
|
||||
*
|
||||
* @param User $user
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function deleteByUser(User $user)
|
||||
{
|
||||
return DB::table($this->tokenTable)
|
||||
return $this->db->table($this->tokenTable)
|
||||
->where('user_id', '=', $user->id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user id from a token, while check the token exists and has not expired.
|
||||
*
|
||||
* @param string $token
|
||||
*
|
||||
* @return int
|
||||
* @throws UserTokenNotFoundException
|
||||
* @throws UserTokenExpiredException
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function checkTokenAndGetUserId(string $token): int
|
||||
public function checkTokenAndGetUserId(string $token) : int
|
||||
{
|
||||
$entry = $this->getEntryByToken($token);
|
||||
|
||||
@@ -67,74 +70,63 @@ class UserTokenService
|
||||
|
||||
/**
|
||||
* Creates a unique token within the email confirmation database.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function generateToken(): string
|
||||
protected function generateToken() : string
|
||||
{
|
||||
$token = Str::random(24);
|
||||
while ($this->tokenExists($token)) {
|
||||
$token = Str::random(25);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and store a token for the given user.
|
||||
*
|
||||
* @param User $user
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function createTokenForUser(User $user): string
|
||||
protected function createTokenForUser(User $user) : string
|
||||
{
|
||||
$token = $this->generateToken();
|
||||
DB::table($this->tokenTable)->insert([
|
||||
'user_id' => $user->id,
|
||||
'token' => $token,
|
||||
$this->db->table($this->tokenTable)->insert([
|
||||
'user_id' => $user->id,
|
||||
'token' => $token,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now()
|
||||
]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given token exists.
|
||||
*
|
||||
* @param string $token
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function tokenExists(string $token): bool
|
||||
protected function tokenExists(string $token) : bool
|
||||
{
|
||||
return DB::table($this->tokenTable)
|
||||
return $this->db->table($this->tokenTable)
|
||||
->where('token', '=', $token)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a token entry for the given token.
|
||||
*
|
||||
* @param string $token
|
||||
*
|
||||
* @return object|null
|
||||
*/
|
||||
protected function getEntryByToken(string $token)
|
||||
{
|
||||
return DB::table($this->tokenTable)
|
||||
return $this->db->table($this->tokenTable)
|
||||
->where('token', '=', $token)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given token entry has expired.
|
||||
*
|
||||
* @param stdClass $tokenEntry
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function entryExpired(stdClass $tokenEntry): bool
|
||||
protected function entryExpired(stdClass $tokenEntry) : bool
|
||||
{
|
||||
return Carbon::now()->subHours($this->expiryTime)
|
||||
->gt(new Carbon($tokenEntry->created_at));
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property ?int $role_id
|
||||
* @property ?int $user_id
|
||||
* @property string $entity_type
|
||||
* @property int $entity_id
|
||||
* @property bool $view
|
||||
*/
|
||||
class CollapsedPermission extends Model
|
||||
{
|
||||
protected $table = 'entity_permissions_collapsed';
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Collapsed permissions act as a "flattened" view of entity-level permissions in the system
|
||||
* so inheritance does not have to managed as part of permission querying.
|
||||
*/
|
||||
class CollapsedPermissionBuilder
|
||||
{
|
||||
/**
|
||||
* Re-generate all collapsed permissions from scratch.
|
||||
*/
|
||||
public function rebuildForAll()
|
||||
{
|
||||
DB::table('entity_permissions_collapsed')->truncate();
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) {
|
||||
$this->buildForBooks($books, false);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->withTrashed()
|
||||
->select(['id'])
|
||||
->chunk(50, function (EloquentCollection $shelves) {
|
||||
$this->generateCollapsedPermissions($shelves->all());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the collapsed permissions for a particular entity.
|
||||
*/
|
||||
public function rebuildForEntity(Entity $entity)
|
||||
{
|
||||
$entities = [$entity];
|
||||
if ($entity instanceof Book) {
|
||||
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
|
||||
$this->buildForBooks($books, true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BookChild $entity */
|
||||
if ($entity->book) {
|
||||
$entities[] = $entity->book;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$entities[] = $entity->chapter;
|
||||
}
|
||||
|
||||
if ($entity instanceof Chapter) {
|
||||
foreach ($entity->pages as $page) {
|
||||
$entities[] = $page;
|
||||
}
|
||||
}
|
||||
|
||||
$this->buildForEntities($entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query for fetching a book with its children.
|
||||
*/
|
||||
protected function bookFetchQuery(): Builder
|
||||
{
|
||||
return Book::query()->withTrashed()
|
||||
->select(['id'])->with([
|
||||
'chapters' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'book_id']);
|
||||
},
|
||||
'pages' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'book_id', 'chapter_id']);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build collapsed permissions for the given books.
|
||||
*/
|
||||
protected function buildForBooks(EloquentCollection $books, bool $deleteOld)
|
||||
{
|
||||
$entities = clone $books;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($books->all() as $book) {
|
||||
foreach ($book->getRelation('chapters') as $chapter) {
|
||||
$entities->push($chapter);
|
||||
}
|
||||
foreach ($book->getRelation('pages') as $page) {
|
||||
$entities->push($page);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteOld) {
|
||||
$this->deleteForEntities($entities->all());
|
||||
}
|
||||
|
||||
$this->generateCollapsedPermissions($entities->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the collapsed permissions for a collection of entities.
|
||||
*/
|
||||
protected function buildForEntities(array $entities)
|
||||
{
|
||||
$this->deleteForEntities($entities);
|
||||
$this->generateCollapsedPermissions($entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored collapsed permissions for a list of entities.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function deleteForEntities(array $entities)
|
||||
{
|
||||
$simpleEntities = $this->entitiesToSimpleEntities($entities);
|
||||
$idsByType = $this->entitiesToTypeIdMap($simpleEntities);
|
||||
|
||||
DB::transaction(function () use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
foreach (array_chunk($ids, 1000) as $idChunk) {
|
||||
DB::table('entity_permissions_collapsed')
|
||||
->where('entity_type', '=', $type)
|
||||
->whereIn('entity_id', $idChunk)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given list of entities into "SimpleEntityData" representations
|
||||
* for faster usage and property access.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*
|
||||
* @return SimpleEntityData[]
|
||||
*/
|
||||
protected function entitiesToSimpleEntities(array $entities): array
|
||||
{
|
||||
$simpleEntities = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$attrs = $entity->getAttributes();
|
||||
$simple = new SimpleEntityData();
|
||||
$simple->id = $attrs['id'];
|
||||
$simple->type = $entity->getMorphClass();
|
||||
$simple->book_id = $attrs['book_id'] ?? null;
|
||||
$simple->chapter_id = $attrs['chapter_id'] ?? null;
|
||||
$simpleEntities[] = $simple;
|
||||
}
|
||||
|
||||
return $simpleEntities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create & Save collapsed entity permissions.
|
||||
*
|
||||
* @param Entity[] $originalEntities
|
||||
*/
|
||||
protected function generateCollapsedPermissions(array $originalEntities)
|
||||
{
|
||||
$entities = $this->entitiesToSimpleEntities($originalEntities);
|
||||
$collapsedPermData = [];
|
||||
|
||||
// Fetch related entity permissions
|
||||
$permissions = $this->getEntityPermissionsForEntities($entities);
|
||||
|
||||
// Create a mapping of explicit entity permissions
|
||||
$permissionMap = new EntityPermissionMap($permissions);
|
||||
|
||||
// Create Joint Permission Data
|
||||
foreach ($entities as $entity) {
|
||||
array_push($collapsedPermData, ...$this->createCollapsedPermissionData($entity, $permissionMap));
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($collapsedPermData) {
|
||||
foreach (array_chunk($collapsedPermData, 1000) as $dataChunk) {
|
||||
DB::table('entity_permissions_collapsed')->insert($dataChunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create collapsed permission data for the given entity using the given permission map.
|
||||
*/
|
||||
protected function createCollapsedPermissionData(SimpleEntityData $entity, EntityPermissionMap $permissionMap): array
|
||||
{
|
||||
$chain = [
|
||||
$entity->type . ':' . $entity->id,
|
||||
$entity->chapter_id ? ('chapter:' . $entity->chapter_id) : null,
|
||||
$entity->book_id ? ('book:' . $entity->book_id) : null,
|
||||
];
|
||||
|
||||
$permissionData = [];
|
||||
$overridesApplied = [];
|
||||
|
||||
foreach ($chain as $entityTypeId) {
|
||||
if ($entityTypeId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permissions = $permissionMap->getForEntity($entityTypeId);
|
||||
foreach ($permissions as $permission) {
|
||||
$related = $permission->getAssignedType() . ':' . $permission->getAssignedTypeId();
|
||||
if (!isset($overridesApplied[$related])) {
|
||||
$permissionData[] = [
|
||||
'role_id' => $permission->role_id,
|
||||
'user_id' => $permission->user_id,
|
||||
'view' => $permission->view,
|
||||
'entity_type' => $entity->type,
|
||||
'entity_id' => $entity->id,
|
||||
];
|
||||
$overridesApplied[$related] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $permissionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* From the given entity list, provide back a mapping of entity types to
|
||||
* the ids of that given type. The type used is the DB morph class.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*
|
||||
* @return array<string, int[]>
|
||||
*/
|
||||
protected function entitiesToTypeIdMap(array $entities): array
|
||||
{
|
||||
$idsByType = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if (!isset($idsByType[$entity->type])) {
|
||||
$idsByType[$entity->type] = [];
|
||||
}
|
||||
|
||||
$idsByType[$entity->type][] = $entity->id;
|
||||
}
|
||||
|
||||
return $idsByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions for all the given entities.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*
|
||||
* @return EntityPermission[]
|
||||
*/
|
||||
protected function getEntityPermissionsForEntities(array $entities): array
|
||||
{
|
||||
$idsByType = $this->entitiesToTypeIdMap($entities);
|
||||
$permissionFetch = EntityPermission::query()
|
||||
->where(function (Builder $query) use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$query->orWhere(function (Builder $query) use ($type, $ids) {
|
||||
$query->where('entity_type', '=', $type)->whereIn('entity_id', $ids);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return $permissionFetch->get()->all();
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,19 @@
|
||||
<?php
|
||||
<?php namespace BookStack\Auth\Permissions;
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $role_id
|
||||
* @property int $user_id
|
||||
* @property int $entity_id
|
||||
* @property string $entity_type
|
||||
* @property boolean $view
|
||||
* @property boolean $create
|
||||
* @property boolean $update
|
||||
* @property boolean $delete
|
||||
*/
|
||||
class EntityPermission extends Model
|
||||
{
|
||||
public const PERMISSIONS = ['view', 'create', 'update', 'delete'];
|
||||
|
||||
protected $fillable = ['role_id', 'user_id', 'view', 'create', 'update', 'delete'];
|
||||
protected $fillable = ['role_id', 'action'];
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Get the role assigned to this entity permission.
|
||||
* Get all this restriction's attached entity.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
public function restrictable()
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user assigned to this entity permission.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of entity permission this is.
|
||||
* Will be one of: user, role, fallback
|
||||
*/
|
||||
public function getAssignedType(): string
|
||||
{
|
||||
if ($this->user_id) {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
if ($this->role_id) {
|
||||
return 'role';
|
||||
}
|
||||
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID for the assigned type of permission.
|
||||
* (Role/User ID). Defaults to 0 for fallback.
|
||||
*/
|
||||
public function getAssignedTypeId(): int
|
||||
{
|
||||
return $this->user_id ?? $this->role_id ?? 0;
|
||||
return $this->morphTo('restrictable');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
class EntityPermissionMap
|
||||
{
|
||||
protected array $map = [];
|
||||
|
||||
/**
|
||||
* @param EntityPermission[] $permissions
|
||||
*/
|
||||
public function __construct(array $permissions = [])
|
||||
{
|
||||
foreach ($permissions as $entityPermission) {
|
||||
$this->addPermission($entityPermission);
|
||||
}
|
||||
}
|
||||
|
||||
protected function addPermission(EntityPermission $permission)
|
||||
{
|
||||
$entityCombinedId = $permission->entity_type . ':' . $permission->entity_id;
|
||||
|
||||
if (!isset($this->map[$entityCombinedId])) {
|
||||
$this->map[$entityCombinedId] = [];
|
||||
}
|
||||
|
||||
$this->map[$entityCombinedId][] = $permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return EntityPermission[]
|
||||
*/
|
||||
public function getForEntity(string $typeIdString): array
|
||||
{
|
||||
return $this->map[$typeIdString] ?? [];
|
||||
}
|
||||
}
|
||||
28
app/Auth/Permissions/JointPermission.php
Normal file
28
app/Auth/Permissions/JointPermission.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Entity;
|
||||
use BookStack\Model;
|
||||
|
||||
class JointPermission extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Get the role that this points to.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function role()
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity this points to.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphOne
|
||||
*/
|
||||
public function entity()
|
||||
{
|
||||
return $this->morphOne(Entity::class, 'entity');
|
||||
}
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Model;
|
||||
use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use BookStack\Traits\HasOwner;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class PermissionApplicator
|
||||
{
|
||||
/**
|
||||
* Checks if an entity has a restriction set upon it.
|
||||
*
|
||||
* @param HasCreatorAndUpdater|HasOwner $ownable
|
||||
*/
|
||||
public function checkOwnableUserAccess(Model $ownable, string $permission): bool
|
||||
{
|
||||
$explodedPermission = explode('-', $permission);
|
||||
$action = $explodedPermission[1] ?? $explodedPermission[0];
|
||||
$fullPermission = count($explodedPermission) > 1 ? $permission : $ownable->getMorphClass() . '-' . $permission;
|
||||
|
||||
$user = $this->currentUser();
|
||||
$userRoleIds = $this->getCurrentUserRoleIds();
|
||||
|
||||
$allRolePermission = $user->can($fullPermission . '-all');
|
||||
$ownRolePermission = $user->can($fullPermission . '-own');
|
||||
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
|
||||
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
|
||||
$ownableFieldVal = $ownable->getAttribute($ownerField);
|
||||
|
||||
if (is_null($ownableFieldVal)) {
|
||||
throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
|
||||
}
|
||||
|
||||
$isOwner = $user->id === $ownableFieldVal;
|
||||
$hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
|
||||
|
||||
// Handle non entity specific jointPermissions
|
||||
if (in_array($explodedPermission[0], $nonJointPermissions)) {
|
||||
return $hasRolePermission;
|
||||
}
|
||||
|
||||
$hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $user->id, $action);
|
||||
|
||||
return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are permissions that are applicable for the given entity item, action and roles.
|
||||
* Returns null when no entity permissions are in force.
|
||||
*/
|
||||
protected function hasEntityPermission(Entity $entity, array $userRoleIds, int $userId, string $action): ?bool
|
||||
{
|
||||
$this->ensureValidEntityAction($action);
|
||||
|
||||
$adminRoleId = Role::getSystemRole('admin')->id;
|
||||
if (in_array($adminRoleId, $userRoleIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The array order here is very important due to the fact we walk up the chain
|
||||
// in the flattening loop below. Earlier items in the chain have higher priority.
|
||||
$typeIdList = [$entity->getMorphClass() . ':' . $entity->id];
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$typeIdList[] = 'chapter:' . $entity->chapter_id;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page || $entity instanceof Chapter) {
|
||||
$typeIdList[] = 'book:' . $entity->book_id;
|
||||
}
|
||||
|
||||
$relevantPermissions = EntityPermission::query()
|
||||
->where(function (Builder $query) use ($typeIdList) {
|
||||
foreach ($typeIdList as $typeId) {
|
||||
$query->orWhere(function (Builder $query) use ($typeId) {
|
||||
[$type, $id] = explode(':', $typeId);
|
||||
$query->where('entity_type', '=', $type)
|
||||
->where('entity_id', '=', $id);
|
||||
});
|
||||
}
|
||||
})->where(function (Builder $query) use ($userRoleIds, $userId) {
|
||||
$query->whereIn('role_id', $userRoleIds)
|
||||
->orWhere('user_id', '=', $userId)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->whereNull(['role_id', 'user_id']);
|
||||
});
|
||||
})->get(['entity_id', 'entity_type', 'role_id', 'user_id', $action])
|
||||
->all();
|
||||
|
||||
$permissionMap = new EntityPermissionMap($relevantPermissions);
|
||||
$permitsByType = ['user' => [], 'fallback' => [], 'role' => []];
|
||||
|
||||
// Collapse and simplify permission structure
|
||||
foreach ($typeIdList as $typeId) {
|
||||
$permissions = $permissionMap->getForEntity($typeId);
|
||||
foreach ($permissions as $permission) {
|
||||
$related = $permission->getAssignedType();
|
||||
$relatedId = $permission->getAssignedTypeId();
|
||||
if (!isset($permitsByType[$related][$relatedId])) {
|
||||
$permitsByType[$related][$relatedId] = $permission->$action;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return user-level permission if exists
|
||||
if (count($permitsByType['user']) > 0) {
|
||||
return boolval(array_values($permitsByType['user'])[0]);
|
||||
}
|
||||
|
||||
// Return grant or reject from role-level if exists
|
||||
if (count($permitsByType['role']) > 0) {
|
||||
return boolval(max($permitsByType['role']));
|
||||
}
|
||||
|
||||
// Return fallback permission if exists
|
||||
if (count($permitsByType['fallback']) > 0) {
|
||||
return boolval($permitsByType['fallback'][0]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has the given permission for any items in the system.
|
||||
* Can be passed an entity instance to filter on a specific type.
|
||||
*/
|
||||
public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
|
||||
{
|
||||
$this->ensureValidEntityAction($action);
|
||||
|
||||
$permissionQuery = EntityPermission::query()
|
||||
->where($action, '=', true)
|
||||
->where(function (Builder $query) {
|
||||
$query->whereIn('role_id', $this->getCurrentUserRoleIds())
|
||||
->orWhere('user_id', '=', $this->currentUser()->id);
|
||||
});
|
||||
|
||||
if (!empty($entityClass)) {
|
||||
/** @var Entity $entityInstance */
|
||||
$entityInstance = app()->make($entityClass);
|
||||
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
|
||||
}
|
||||
|
||||
$hasPermission = $permissionQuery->count() > 0;
|
||||
|
||||
return $hasPermission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit the given entity query so that the query will only
|
||||
* return items that the user has view permission for.
|
||||
*/
|
||||
public function restrictEntityQuery(Builder $query, string $morphClass): Builder
|
||||
{
|
||||
$this->applyPermissionsToQuery($query, $query->getModel()->getTable(), $morphClass, 'id', '');
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyPermissionsToQuery($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn): void
|
||||
{
|
||||
if ($this->currentUser()->hasSystemRole('admin')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyFallbackJoin($query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
$this->applyRoleJoin($query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
$this->applyUserJoin($query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
$this->applyPermissionWhereFilter($query, $queryTable, $entityTypeLimiter, $entityTypeColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the where condition to a permission restricting query, to limit based upon the values of the joined
|
||||
* permission data. Query must have joins pre-applied.
|
||||
* Either entityTypeLimiter or entityTypeColumn should be supplied, with the other empty.
|
||||
* Both should not be applied since that would conflict upon intent.
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyPermissionWhereFilter($query, string $queryTable, string $entityTypeLimiter, string $entityTypeColumn)
|
||||
{
|
||||
$abilities = ['all' => [], 'own' => []];
|
||||
$types = $entityTypeLimiter ? [$entityTypeLimiter] : ['page', 'chapter', 'bookshelf', 'book'];
|
||||
$fullEntityTypeColumn = $queryTable . '.' . $entityTypeColumn;
|
||||
foreach ($types as $type) {
|
||||
$abilities['all'][$type] = userCan($type . '-view-all');
|
||||
$abilities['own'][$type] = userCan($type . '-view-own');
|
||||
}
|
||||
|
||||
$abilities['all'] = array_filter($abilities['all']);
|
||||
$abilities['own'] = array_filter($abilities['own']);
|
||||
|
||||
$query->where(function (Builder $query) use ($abilities, $fullEntityTypeColumn, $entityTypeColumn) {
|
||||
$query->where('perms_user', '=', 1)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->whereNull('perms_user')->where('perms_role', '=', 1);
|
||||
})->orWhere(function (Builder $query) {
|
||||
$query->whereNull(['perms_user', 'perms_role'])
|
||||
->where('perms_fallback', '=', 1);
|
||||
});
|
||||
|
||||
if (count($abilities['all']) > 0) {
|
||||
$query->orWhere(function (Builder $query) use ($abilities, $fullEntityTypeColumn, $entityTypeColumn) {
|
||||
$query->whereNull(['perms_user', 'perms_role', 'perms_fallback']);
|
||||
if ($entityTypeColumn) {
|
||||
$query->whereIn($fullEntityTypeColumn, array_keys($abilities['all']));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (count($abilities['own']) > 0) {
|
||||
$query->orWhere(function (Builder $query) use ($abilities, $fullEntityTypeColumn, $entityTypeColumn) {
|
||||
$query->whereNull(['perms_user', 'perms_role', 'perms_fallback'])
|
||||
->where('owned_by', '=', $this->currentUser()->id);
|
||||
if ($entityTypeColumn) {
|
||||
$query->whereIn($fullEntityTypeColumn, array_keys($abilities['all']));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyPermissionJoin(callable $joinCallable, string $subAlias, $query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$joinCondition = $this->getJoinCondition($queryTable, $subAlias, $entityIdColumn, $entityTypeColumn);
|
||||
|
||||
$query->joinSub(function (QueryBuilder $joinQuery) use ($joinCallable, $entityTypeLimiter) {
|
||||
$joinQuery->select(['entity_id', 'entity_type'])->from('entity_permissions_collapsed')
|
||||
->groupBy('entity_id', 'entity_type');
|
||||
$joinCallable($joinQuery);
|
||||
|
||||
if ($entityTypeLimiter) {
|
||||
$joinQuery->where('entity_type', '=', $entityTypeLimiter);
|
||||
}
|
||||
}, $subAlias, $joinCondition, null, null, 'left');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyUserJoin($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$this->applyPermissionJoin(function (QueryBuilder $joinQuery) {
|
||||
$joinQuery->selectRaw('max(view) as perms_user')
|
||||
->where('user_id', '=', $this->currentUser()->id);
|
||||
}, 'p_u', $query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyRoleJoin($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$this->applyPermissionJoin(function (QueryBuilder $joinQuery) {
|
||||
$joinQuery->selectRaw('max(view) as perms_role')
|
||||
->whereIn('role_id', $this->getCurrentUserRoleIds());
|
||||
}, 'p_r', $query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyFallbackJoin($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$this->applyPermissionJoin(function (QueryBuilder $joinQuery) {
|
||||
$joinQuery->selectRaw('max(view) as perms_fallback')
|
||||
->whereNull(['role_id', 'user_id']);
|
||||
}, 'p_f', $query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
}
|
||||
|
||||
protected function getJoinCondition(string $queryTable, string $joinTableName, string $entityIdColumn, string $entityTypeColumn): callable
|
||||
{
|
||||
return function (JoinClause $join) use ($queryTable, $joinTableName, $entityIdColumn, $entityTypeColumn) {
|
||||
$join->on($queryTable . '.' . $entityIdColumn, '=', $joinTableName . '.entity_id');
|
||||
if ($entityTypeColumn) {
|
||||
$join->on($queryTable . '.' . $entityTypeColumn, '=', $joinTableName . '.entity_type');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the given page query to ensure draft items are not visible
|
||||
* unless created by the given user.
|
||||
*/
|
||||
public function restrictDraftsOnPageQuery(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query) {
|
||||
$query->where('draft', '=', false)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->where('draft', '=', true)
|
||||
->where('owned_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter items that have entities set as a polymorphic relation.
|
||||
* For simplicity, this will not return results attached to draft pages.
|
||||
* Draft pages should never really have related items though.
|
||||
*
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
public function restrictEntityRelationQuery($query, string $tableName, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$query->leftJoinSub(function (QueryBuilder $query) {
|
||||
$query->select(['id as entity_id', DB::raw("'page' as entity_type"), 'owned_by', 'deleted_at', 'draft'])->from('pages');
|
||||
$tablesByType = ['page' => 'pages', 'book' => 'books', 'chapter' => 'chapters', 'bookshelf' => 'bookshelves'];
|
||||
foreach ($tablesByType as $type => $table) {
|
||||
$query->unionAll(function (QueryBuilder $query) use ($type, $table) {
|
||||
$query->select(['id as entity_id', DB::raw("'{$type}' as entity_type"), 'owned_by', 'deleted_at', DB::raw('0 as draft')])->from($table);
|
||||
});
|
||||
}
|
||||
}, 'entities', function (JoinClause $join) use ($tableName, $entityIdColumn, $entityTypeColumn) {
|
||||
$join->on($tableName . '.' . $entityIdColumn, '=', 'entities.entity_id')
|
||||
->on($tableName . '.' . $entityTypeColumn, '=', 'entities.entity_type');
|
||||
});
|
||||
|
||||
$this->applyPermissionsToQuery($query, $tableName, '', $entityIdColumn, $entityTypeColumn);
|
||||
// TODO - Test page draft access (Might allow drafts which should not be seen)
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add conditions to a query for a model that's a relation of a page, so only the model results
|
||||
* on visible pages are returned by the query.
|
||||
* Is effectively the same as "restrictEntityRelationQuery" but takes into account page drafts
|
||||
* while not expecting a polymorphic relation, Just a simpler one-page-to-many-relations set-up.
|
||||
*/
|
||||
public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder
|
||||
{
|
||||
$morphClass = (new Page())->getMorphClass();
|
||||
|
||||
$this->applyPermissionsToQuery($query, $tableName, $morphClass, $pageIdColumn, '');
|
||||
// TODO - Draft display
|
||||
// TODO - Likely need owned_by entity join workaround as used above
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user.
|
||||
*/
|
||||
protected function currentUser(): User
|
||||
{
|
||||
return user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles for the current logged-in user.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
protected function getCurrentUserRoleIds(): array
|
||||
{
|
||||
if (auth()->guest()) {
|
||||
return [Role::getSystemRole('public')->id];
|
||||
}
|
||||
|
||||
return $this->currentUser()->roles->pluck('id')->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the given action is a valid and expected entity action.
|
||||
* Throws an exception if invalid otherwise does nothing.
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function ensureValidEntityAction(string $action): void
|
||||
{
|
||||
if (!in_array($action, EntityPermission::PERMISSIONS)) {
|
||||
throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class PermissionFormData
|
||||
{
|
||||
protected Entity $entity;
|
||||
|
||||
public function __construct(Entity $entity)
|
||||
{
|
||||
$this->entity = $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permissions with assigned roles.
|
||||
*/
|
||||
public function permissionsWithRoles(): array
|
||||
{
|
||||
return $this->entity->permissions()
|
||||
->with('role')
|
||||
->whereNotNull('role_id')
|
||||
->get()
|
||||
->sortBy('role.display_name')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permissions with assigned users.
|
||||
*/
|
||||
public function permissionsWithUsers(): array
|
||||
{
|
||||
return $this->entity->permissions()
|
||||
->with('user')
|
||||
->whereNotNull('user_id')
|
||||
->get()
|
||||
->sortBy('user.name')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles that don't yet have specific permissions for the
|
||||
* entity we're managing permissions for.
|
||||
*/
|
||||
public function rolesNotAssigned(): array
|
||||
{
|
||||
$assigned = $this->entity->permissions()->whereNotNull('role_id')->pluck('role_id');
|
||||
return Role::query()
|
||||
->where('system_name', '!=', 'admin')
|
||||
->whereNotIn('id', $assigned)
|
||||
->orderBy('display_name', 'asc')
|
||||
->get()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permission for the "Everyone Else" option.
|
||||
*/
|
||||
public function everyoneElseEntityPermission(): EntityPermission
|
||||
{
|
||||
/** @var ?EntityPermission $permission */
|
||||
$permission = $this->entity->permissions()
|
||||
->whereNull(['role_id', 'user_id'])
|
||||
->first();
|
||||
return $permission ?? (new EntityPermission());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the "Everyone else" option is inheriting default role system permissions.
|
||||
* Is determined by any system entity_permission existing for the current entity.
|
||||
*/
|
||||
public function everyoneElseInheriting(): bool
|
||||
{
|
||||
return !$this->entity->permissions()
|
||||
->whereNull(['role_id', 'user_id'])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
791
app/Auth/Permissions/PermissionService.php
Normal file
791
app/Auth/Permissions/PermissionService.php
Normal file
@@ -0,0 +1,791 @@
|
||||
<?php namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Permissions;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Book;
|
||||
use BookStack\Entities\Bookshelf;
|
||||
use BookStack\Entities\Chapter;
|
||||
use BookStack\Entities\Entity;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Page;
|
||||
use BookStack\Ownable;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class PermissionService
|
||||
{
|
||||
|
||||
protected $currentAction;
|
||||
protected $isAdminUser;
|
||||
protected $userRoles = false;
|
||||
protected $currentUserModel = false;
|
||||
|
||||
/**
|
||||
* @var Connection
|
||||
*/
|
||||
protected $db;
|
||||
|
||||
/**
|
||||
* @var JointPermission
|
||||
*/
|
||||
protected $jointPermission;
|
||||
|
||||
/**
|
||||
* @var Role
|
||||
*/
|
||||
protected $role;
|
||||
|
||||
/**
|
||||
* @var EntityPermission
|
||||
*/
|
||||
protected $entityPermission;
|
||||
|
||||
/**
|
||||
* @var EntityProvider
|
||||
*/
|
||||
protected $entityProvider;
|
||||
|
||||
protected $entityCache;
|
||||
|
||||
/**
|
||||
* PermissionService constructor.
|
||||
* @param JointPermission $jointPermission
|
||||
* @param EntityPermission $entityPermission
|
||||
* @param Role $role
|
||||
* @param Connection $db
|
||||
* @param EntityProvider $entityProvider
|
||||
*/
|
||||
public function __construct(
|
||||
JointPermission $jointPermission,
|
||||
Permissions\EntityPermission $entityPermission,
|
||||
Role $role,
|
||||
Connection $db,
|
||||
EntityProvider $entityProvider
|
||||
) {
|
||||
$this->db = $db;
|
||||
$this->jointPermission = $jointPermission;
|
||||
$this->entityPermission = $entityPermission;
|
||||
$this->role = $role;
|
||||
$this->entityProvider = $entityProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the database connection
|
||||
* @param Connection $connection
|
||||
*/
|
||||
public function setConnection(Connection $connection)
|
||||
{
|
||||
$this->db = $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the local entity cache and ensure it's empty
|
||||
* @param \BookStack\Entities\Entity[] $entities
|
||||
*/
|
||||
protected function readyEntityCache($entities = [])
|
||||
{
|
||||
$this->entityCache = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$type = $entity->getType();
|
||||
if (!isset($this->entityCache[$type])) {
|
||||
$this->entityCache[$type] = collect();
|
||||
}
|
||||
$this->entityCache[$type]->put($entity->id, $entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a book via ID, Checks local cache
|
||||
* @param $bookId
|
||||
* @return Book
|
||||
*/
|
||||
protected function getBook($bookId)
|
||||
{
|
||||
if (isset($this->entityCache['book']) && $this->entityCache['book']->has($bookId)) {
|
||||
return $this->entityCache['book']->get($bookId);
|
||||
}
|
||||
|
||||
$book = $this->entityProvider->book->find($bookId);
|
||||
if ($book === null) {
|
||||
$book = false;
|
||||
}
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a chapter via ID, Checks local cache
|
||||
* @param $chapterId
|
||||
* @return \BookStack\Entities\Book
|
||||
*/
|
||||
protected function getChapter($chapterId)
|
||||
{
|
||||
if (isset($this->entityCache['chapter']) && $this->entityCache['chapter']->has($chapterId)) {
|
||||
return $this->entityCache['chapter']->get($chapterId);
|
||||
}
|
||||
|
||||
$chapter = $this->entityProvider->chapter->find($chapterId);
|
||||
if ($chapter === null) {
|
||||
$chapter = false;
|
||||
}
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles for the current user;
|
||||
* @return array|bool
|
||||
*/
|
||||
protected function getRoles()
|
||||
{
|
||||
if ($this->userRoles !== false) {
|
||||
return $this->userRoles;
|
||||
}
|
||||
|
||||
$roles = [];
|
||||
|
||||
if (auth()->guest()) {
|
||||
$roles[] = $this->role->getSystemRole('public')->id;
|
||||
return $roles;
|
||||
}
|
||||
|
||||
|
||||
foreach ($this->currentUser()->roles as $role) {
|
||||
$roles[] = $role->id;
|
||||
}
|
||||
return $roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-generate all entity permission from scratch.
|
||||
*/
|
||||
public function buildJointPermissions()
|
||||
{
|
||||
$this->jointPermission->truncate();
|
||||
$this->readyEntityCache();
|
||||
|
||||
// Get all roles (Should be the most limited dimension)
|
||||
$roles = $this->role->with('permissions')->get()->all();
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(5, function ($books) use ($roles) {
|
||||
$this->buildJointPermissionsForBooks($books, $roles);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
$this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
|
||||
->chunk(50, function ($shelves) use ($roles) {
|
||||
$this->buildJointPermissionsForShelves($shelves, $roles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query for fetching a book with it's children.
|
||||
* @return QueryBuilder
|
||||
*/
|
||||
protected function bookFetchQuery()
|
||||
{
|
||||
return $this->entityProvider->book->newQuery()
|
||||
->select(['id', 'restricted', 'created_by'])->with(['chapters' => function ($query) {
|
||||
$query->select(['id', 'restricted', 'created_by', 'book_id']);
|
||||
}, 'pages' => function ($query) {
|
||||
$query->select(['id', 'restricted', 'created_by', 'book_id', 'chapter_id']);
|
||||
}]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection $shelves
|
||||
* @param array $roles
|
||||
* @param bool $deleteOld
|
||||
* @throws \Throwable
|
||||
*/
|
||||
protected function buildJointPermissionsForShelves($shelves, $roles, $deleteOld = false)
|
||||
{
|
||||
if ($deleteOld) {
|
||||
$this->deleteManyJointPermissionsForEntities($shelves->all());
|
||||
}
|
||||
$this->createManyJointPermissions($shelves, $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build joint permissions for an array of books
|
||||
* @param Collection $books
|
||||
* @param array $roles
|
||||
* @param bool $deleteOld
|
||||
*/
|
||||
protected function buildJointPermissionsForBooks($books, $roles, $deleteOld = false)
|
||||
{
|
||||
$entities = clone $books;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($books->all() as $book) {
|
||||
foreach ($book->getRelation('chapters') as $chapter) {
|
||||
$entities->push($chapter);
|
||||
}
|
||||
foreach ($book->getRelation('pages') as $page) {
|
||||
$entities->push($page);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteOld) {
|
||||
$this->deleteManyJointPermissionsForEntities($entities->all());
|
||||
}
|
||||
$this->createManyJointPermissions($entities, $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entity jointPermissions for a particular entity.
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function buildJointPermissionsForEntity(Entity $entity)
|
||||
{
|
||||
$entities = [$entity];
|
||||
if ($entity->isA('book')) {
|
||||
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
|
||||
$this->buildJointPermissionsForBooks($books, $this->role->newQuery()->get(), true);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($entity->book) {
|
||||
$entities[] = $entity->book;
|
||||
}
|
||||
|
||||
if ($entity->isA('page') && $entity->chapter_id) {
|
||||
$entities[] = $entity->chapter;
|
||||
}
|
||||
|
||||
if ($entity->isA('chapter')) {
|
||||
foreach ($entity->pages as $page) {
|
||||
$entities[] = $page;
|
||||
}
|
||||
}
|
||||
|
||||
$this->buildJointPermissionsForEntities(collect($entities));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entity jointPermissions for a collection of entities.
|
||||
* @param Collection $entities
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function buildJointPermissionsForEntities(Collection $entities)
|
||||
{
|
||||
$roles = $this->role->newQuery()->get();
|
||||
$this->deleteManyJointPermissionsForEntities($entities->all());
|
||||
$this->createManyJointPermissions($entities, $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the entity jointPermissions for a particular role.
|
||||
* @param Role $role
|
||||
*/
|
||||
public function buildJointPermissionForRole(Role $role)
|
||||
{
|
||||
$roles = [$role];
|
||||
$this->deleteManyJointPermissionsForRoles($roles);
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
|
||||
$this->buildJointPermissionsForBooks($books, $roles);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
$this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
|
||||
->chunk(50, function ($shelves) use ($roles) {
|
||||
$this->buildJointPermissionsForShelves($shelves, $roles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the entity jointPermissions attached to a particular role.
|
||||
* @param Role $role
|
||||
*/
|
||||
public function deleteJointPermissionsForRole(Role $role)
|
||||
{
|
||||
$this->deleteManyJointPermissionsForRoles([$role]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all of the entity jointPermissions for a list of entities.
|
||||
* @param Role[] $roles
|
||||
*/
|
||||
protected function deleteManyJointPermissionsForRoles($roles)
|
||||
{
|
||||
$roleIds = array_map(function ($role) {
|
||||
return $role->id;
|
||||
}, $roles);
|
||||
$this->jointPermission->newQuery()->whereIn('role_id', $roleIds)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the entity jointPermissions for a particular entity.
|
||||
* @param Entity $entity
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function deleteJointPermissionsForEntity(Entity $entity)
|
||||
{
|
||||
$this->deleteManyJointPermissionsForEntities([$entity]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all of the entity jointPermissions for a list of entities.
|
||||
* @param \BookStack\Entities\Entity[] $entities
|
||||
* @throws \Throwable
|
||||
*/
|
||||
protected function deleteManyJointPermissionsForEntities($entities)
|
||||
{
|
||||
if (count($entities) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->transaction(function () use ($entities) {
|
||||
|
||||
foreach (array_chunk($entities, 1000) as $entityChunk) {
|
||||
$query = $this->db->table('joint_permissions');
|
||||
foreach ($entityChunk as $entity) {
|
||||
$query->orWhere(function (QueryBuilder $query) use ($entity) {
|
||||
$query->where('entity_id', '=', $entity->id)
|
||||
->where('entity_type', '=', $entity->getMorphClass());
|
||||
});
|
||||
}
|
||||
$query->delete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create & Save entity jointPermissions for many entities and jointPermissions.
|
||||
* @param Collection $entities
|
||||
* @param array $roles
|
||||
* @throws \Throwable
|
||||
*/
|
||||
protected function createManyJointPermissions($entities, $roles)
|
||||
{
|
||||
$this->readyEntityCache($entities);
|
||||
$jointPermissions = [];
|
||||
|
||||
// Fetch Entity Permissions and create a mapping of entity restricted statuses
|
||||
$entityRestrictedMap = [];
|
||||
$permissionFetch = $this->entityPermission->newQuery();
|
||||
foreach ($entities as $entity) {
|
||||
$entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted'));
|
||||
$permissionFetch->orWhere(function ($query) use ($entity) {
|
||||
$query->where('restrictable_id', '=', $entity->id)->where('restrictable_type', '=', $entity->getMorphClass());
|
||||
});
|
||||
}
|
||||
$permissions = $permissionFetch->get();
|
||||
|
||||
// Create a mapping of explicit entity permissions
|
||||
$permissionMap = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id . ':' . $permission->action;
|
||||
$isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
|
||||
$permissionMap[$key] = $isRestricted;
|
||||
}
|
||||
|
||||
// Create a mapping of role permissions
|
||||
$rolePermissionMap = [];
|
||||
foreach ($roles as $role) {
|
||||
foreach ($role->permissions as $permission) {
|
||||
$rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Create Joint Permission Data
|
||||
foreach ($entities as $entity) {
|
||||
foreach ($roles as $role) {
|
||||
foreach ($this->getActions($entity) as $action) {
|
||||
$jointPermissions[] = $this->createJointPermissionData($entity, $role, $action, $permissionMap, $rolePermissionMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transaction(function () use ($jointPermissions) {
|
||||
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
|
||||
$this->db->table('joint_permissions')->insert($jointPermissionChunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the actions related to an entity.
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @return array
|
||||
*/
|
||||
protected function getActions(Entity $entity)
|
||||
{
|
||||
$baseActions = ['view', 'update', 'delete'];
|
||||
if ($entity->isA('chapter') || $entity->isA('book')) {
|
||||
$baseActions[] = 'page-create';
|
||||
}
|
||||
if ($entity->isA('book')) {
|
||||
$baseActions[] = 'chapter-create';
|
||||
}
|
||||
return $baseActions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create entity permission data for an entity and role
|
||||
* for a particular action.
|
||||
* @param Entity $entity
|
||||
* @param Role $role
|
||||
* @param string $action
|
||||
* @param array $permissionMap
|
||||
* @param array $rolePermissionMap
|
||||
* @return array
|
||||
*/
|
||||
protected function createJointPermissionData(Entity $entity, Role $role, $action, $permissionMap, $rolePermissionMap)
|
||||
{
|
||||
$permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action;
|
||||
$roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']);
|
||||
$roleHasPermissionOwn = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-own']);
|
||||
$explodedAction = explode('-', $action);
|
||||
$restrictionAction = end($explodedAction);
|
||||
|
||||
if ($role->system_name === 'admin') {
|
||||
return $this->createJointPermissionDataArray($entity, $role, $action, true, true);
|
||||
}
|
||||
|
||||
if ($entity->restricted) {
|
||||
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $role, $restrictionAction);
|
||||
return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
|
||||
}
|
||||
|
||||
if ($entity->isA('book') || $entity->isA('bookshelf')) {
|
||||
return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn);
|
||||
}
|
||||
|
||||
// For chapters and pages, Check if explicit permissions are set on the Book.
|
||||
$book = $this->getBook($entity->book_id);
|
||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $role, $restrictionAction);
|
||||
$hasPermissiveAccessToParents = !$book->restricted;
|
||||
|
||||
// For pages with a chapter, Check if explicit permissions are set on the Chapter
|
||||
if ($entity->isA('page') && $entity->chapter_id !== 0 && $entity->chapter_id !== '0') {
|
||||
$chapter = $this->getChapter($entity->chapter_id);
|
||||
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
|
||||
if ($chapter->restricted) {
|
||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $role, $restrictionAction);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createJointPermissionDataArray(
|
||||
$entity,
|
||||
$role,
|
||||
$action,
|
||||
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
|
||||
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for an active restriction in an entity map.
|
||||
* @param $entityMap
|
||||
* @param Entity $entity
|
||||
* @param Role $role
|
||||
* @param $action
|
||||
* @return bool
|
||||
*/
|
||||
protected function mapHasActiveRestriction($entityMap, Entity $entity, Role $role, $action)
|
||||
{
|
||||
$key = $entity->getMorphClass() . ':' . $entity->getRawAttribute('id') . ':' . $role->getRawAttribute('id') . ':' . $action;
|
||||
return isset($entityMap[$key]) ? $entityMap[$key] : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an array of data with the information of an entity jointPermissions.
|
||||
* Used to build data for bulk insertion.
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @param Role $role
|
||||
* @param $action
|
||||
* @param $permissionAll
|
||||
* @param $permissionOwn
|
||||
* @return array
|
||||
*/
|
||||
protected function createJointPermissionDataArray(Entity $entity, Role $role, $action, $permissionAll, $permissionOwn)
|
||||
{
|
||||
return [
|
||||
'role_id' => $role->getRawAttribute('id'),
|
||||
'entity_id' => $entity->getRawAttribute('id'),
|
||||
'entity_type' => $entity->getMorphClass(),
|
||||
'action' => $action,
|
||||
'has_permission' => $permissionAll,
|
||||
'has_permission_own' => $permissionOwn,
|
||||
'created_by' => $entity->getRawAttribute('created_by')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an entity has a restriction set upon it.
|
||||
* @param Ownable $ownable
|
||||
* @param $permission
|
||||
* @return bool
|
||||
*/
|
||||
public function checkOwnableUserAccess(Ownable $ownable, $permission)
|
||||
{
|
||||
$explodedPermission = explode('-', $permission);
|
||||
|
||||
$baseQuery = $ownable->where('id', '=', $ownable->id);
|
||||
$action = end($explodedPermission);
|
||||
$this->currentAction = $action;
|
||||
|
||||
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
|
||||
|
||||
// Handle non entity specific jointPermissions
|
||||
if (in_array($explodedPermission[0], $nonJointPermissions)) {
|
||||
$allPermission = $this->currentUser() && $this->currentUser()->can($permission . '-all');
|
||||
$ownPermission = $this->currentUser() && $this->currentUser()->can($permission . '-own');
|
||||
$this->currentAction = 'view';
|
||||
$isOwner = $this->currentUser() && $this->currentUser()->id === $ownable->created_by;
|
||||
return ($allPermission || ($isOwner && $ownPermission));
|
||||
}
|
||||
|
||||
// Handle abnormal create jointPermissions
|
||||
if ($action === 'create') {
|
||||
$this->currentAction = $permission;
|
||||
}
|
||||
|
||||
$q = $this->entityRestrictionQuery($baseQuery)->count() > 0;
|
||||
$this->clean();
|
||||
return $q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has the given permission for any items in the system.
|
||||
* Can be passed an entity instance to filter on a specific type.
|
||||
* @param string $permission
|
||||
* @param string $entityClass
|
||||
* @return bool
|
||||
*/
|
||||
public function checkUserHasPermissionOnAnything(string $permission, string $entityClass = null)
|
||||
{
|
||||
$userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray();
|
||||
$userId = $this->currentUser()->id;
|
||||
|
||||
$permissionQuery = $this->db->table('joint_permissions')
|
||||
->where('action', '=', $permission)
|
||||
->whereIn('role_id', $userRoleIds)
|
||||
->where(function ($query) use ($userId) {
|
||||
$query->where('has_permission', '=', 1)
|
||||
->orWhere(function ($query2) use ($userId) {
|
||||
$query2->where('has_permission_own', '=', 1)
|
||||
->where('created_by', '=', $userId);
|
||||
});
|
||||
});
|
||||
|
||||
if (!is_null($entityClass)) {
|
||||
$entityInstance = app()->make($entityClass);
|
||||
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
|
||||
}
|
||||
|
||||
$hasPermission = $permissionQuery->count() > 0;
|
||||
$this->clean();
|
||||
return $hasPermission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an entity has restrictions set on itself or its
|
||||
* parent tree.
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @param $action
|
||||
* @return bool|mixed
|
||||
*/
|
||||
public function checkIfRestrictionsSet(Entity $entity, $action)
|
||||
{
|
||||
$this->currentAction = $action;
|
||||
if ($entity->isA('page')) {
|
||||
return $entity->restricted || ($entity->chapter && $entity->chapter->restricted) || $entity->book->restricted;
|
||||
} elseif ($entity->isA('chapter')) {
|
||||
return $entity->restricted || $entity->book->restricted;
|
||||
} elseif ($entity->isA('book')) {
|
||||
return $entity->restricted;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The general query filter to remove all entities
|
||||
* that the current user does not have access to.
|
||||
* @param $query
|
||||
* @return mixed
|
||||
*/
|
||||
protected function entityRestrictionQuery($query)
|
||||
{
|
||||
$q = $query->where(function ($parentQuery) {
|
||||
$parentQuery->whereHas('jointPermissions', function ($permissionQuery) {
|
||||
$permissionQuery->whereIn('role_id', $this->getRoles())
|
||||
->where('action', '=', $this->currentAction)
|
||||
->where(function ($query) {
|
||||
$query->where('has_permission', '=', true)
|
||||
->orWhere(function ($query) {
|
||||
$query->where('has_permission_own', '=', true)
|
||||
->where('created_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
$this->clean();
|
||||
return $q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limited the given entity query so that the query will only
|
||||
* return items that the user has permission for the given ability.
|
||||
*/
|
||||
public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
|
||||
{
|
||||
$this->clean();
|
||||
return $query->where(function (Builder $parentQuery) use ($ability) {
|
||||
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
|
||||
$permissionQuery->whereIn('role_id', $this->getRoles())
|
||||
->where('action', '=', $ability)
|
||||
->where(function (Builder $query) {
|
||||
$query->where('has_permission', '=', true)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->where('has_permission_own', '=', true)
|
||||
->where('created_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the given page query to ensure draft items are not visible
|
||||
* unless created by the given user.
|
||||
*/
|
||||
public function enforceDraftVisiblityOnQuery(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query) {
|
||||
$query->where('draft', '=', false)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->where('draft', '=', true)
|
||||
->where('created_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add restrictions for a generic entity
|
||||
* @param string $entityType
|
||||
* @param Builder|\BookStack\Entities\Entity $query
|
||||
* @param string $action
|
||||
* @return Builder
|
||||
*/
|
||||
public function enforceEntityRestrictions($entityType, $query, $action = 'view')
|
||||
{
|
||||
if (strtolower($entityType) === 'page') {
|
||||
// Prevent drafts being visible to others.
|
||||
$query = $query->where(function ($query) {
|
||||
$query->where('draft', '=', false)
|
||||
->orWhere(function ($query) {
|
||||
$query->where('draft', '=', true)
|
||||
->where('created_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$this->currentAction = $action;
|
||||
return $this->entityRestrictionQuery($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter items that have entities set as a polymorphic relation.
|
||||
* @param $query
|
||||
* @param string $tableName
|
||||
* @param string $entityIdColumn
|
||||
* @param string $entityTypeColumn
|
||||
* @param string $action
|
||||
* @return QueryBuilder
|
||||
*/
|
||||
public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn, $action = 'view')
|
||||
{
|
||||
|
||||
$this->currentAction = $action;
|
||||
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
|
||||
|
||||
$q = $query->where(function ($query) use ($tableDetails) {
|
||||
$query->whereExists(function ($permissionQuery) use (&$tableDetails) {
|
||||
$permissionQuery->select('id')->from('joint_permissions')
|
||||
->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
|
||||
->where('action', '=', $this->currentAction)
|
||||
->whereIn('role_id', $this->getRoles())
|
||||
->where(function ($query) {
|
||||
$query->where('has_permission', '=', true)->orWhere(function ($query) {
|
||||
$query->where('has_permission_own', '=', true)
|
||||
->where('created_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
$this->clean();
|
||||
return $q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add conditions to a query to filter the selection to related entities
|
||||
* where permissions are granted.
|
||||
* @param $entityType
|
||||
* @param $query
|
||||
* @param $tableName
|
||||
* @param $entityIdColumn
|
||||
* @return mixed
|
||||
*/
|
||||
public function filterRelatedEntity($entityType, $query, $tableName, $entityIdColumn)
|
||||
{
|
||||
$this->currentAction = 'view';
|
||||
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
|
||||
|
||||
$pageMorphClass = $this->entityProvider->get($entityType)->getMorphClass();
|
||||
|
||||
$q = $query->where(function ($query) use ($tableDetails, $pageMorphClass) {
|
||||
$query->where(function ($query) use (&$tableDetails, $pageMorphClass) {
|
||||
$query->whereExists(function ($permissionQuery) use (&$tableDetails, $pageMorphClass) {
|
||||
$permissionQuery->select('id')->from('joint_permissions')
|
||||
->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
->where('entity_type', '=', $pageMorphClass)
|
||||
->where('action', '=', $this->currentAction)
|
||||
->whereIn('role_id', $this->getRoles())
|
||||
->where(function ($query) {
|
||||
$query->where('has_permission', '=', true)->orWhere(function ($query) {
|
||||
$query->where('has_permission_own', '=', true)
|
||||
->where('created_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
});
|
||||
})->orWhere($tableDetails['entityIdColumn'], '=', 0);
|
||||
});
|
||||
|
||||
$this->clean();
|
||||
|
||||
return $q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user
|
||||
* @return \BookStack\Auth\User
|
||||
*/
|
||||
private function currentUser()
|
||||
{
|
||||
if ($this->currentUserModel === false) {
|
||||
$this->currentUserModel = user();
|
||||
}
|
||||
|
||||
return $this->currentUserModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the cached user elements.
|
||||
*/
|
||||
private function clean()
|
||||
{
|
||||
$this->currentUserModel = false;
|
||||
$this->userRoles = false;
|
||||
$this->isAdminUser = null;
|
||||
}
|
||||
}
|
||||
@@ -1,75 +1,92 @@
|
||||
<?php
|
||||
<?php namespace BookStack\Auth\Permissions;
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Permissions;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Facades\Activity;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PermissionsRepo
|
||||
{
|
||||
protected CollapsedPermissionBuilder $permissionBuilder;
|
||||
protected array $systemRoles = ['admin', 'public'];
|
||||
|
||||
protected $permission;
|
||||
protected $role;
|
||||
protected $permissionService;
|
||||
|
||||
protected $systemRoles = ['admin', 'public'];
|
||||
|
||||
/**
|
||||
* PermissionsRepo constructor.
|
||||
* @param RolePermission $permission
|
||||
* @param Role $role
|
||||
* @param \BookStack\Auth\Permissions\PermissionService $permissionService
|
||||
*/
|
||||
public function __construct(CollapsedPermissionBuilder $permissionBuilder)
|
||||
public function __construct(RolePermission $permission, Role $role, Permissions\PermissionService $permissionService)
|
||||
{
|
||||
$this->permissionBuilder = $permissionBuilder;
|
||||
$this->permission = $permission;
|
||||
$this->role = $role;
|
||||
$this->permissionService = $permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the user roles from the system.
|
||||
* @return \Illuminate\Database\Eloquent\Collection|static[]
|
||||
*/
|
||||
public function getAllRoles(): Collection
|
||||
public function getAllRoles()
|
||||
{
|
||||
return Role::query()->get();
|
||||
return $this->role->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the roles except for the provided one.
|
||||
* @param Role $role
|
||||
* @return mixed
|
||||
*/
|
||||
public function getAllRolesExcept(Role $role): Collection
|
||||
public function getAllRolesExcept(Role $role)
|
||||
{
|
||||
return Role::query()->where('id', '!=', $role->id)->get();
|
||||
return $this->role->where('id', '!=', $role->id)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a role via its ID.
|
||||
* @param $id
|
||||
* @return mixed
|
||||
*/
|
||||
public function getRoleById($id): Role
|
||||
public function getRoleById($id)
|
||||
{
|
||||
return Role::query()->findOrFail($id);
|
||||
return $this->role->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new role into the system.
|
||||
* @param array $roleData
|
||||
* @return Role
|
||||
*/
|
||||
public function saveNewRole(array $roleData): Role
|
||||
public function saveNewRole($roleData)
|
||||
{
|
||||
$role = new Role($roleData);
|
||||
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
|
||||
$role = $this->role->newInstance($roleData);
|
||||
$role->name = str_replace(' ', '-', strtolower($roleData['display_name']));
|
||||
// Prevent duplicate names
|
||||
while ($this->role->where('name', '=', $role->name)->count() > 0) {
|
||||
$role->name .= strtolower(Str::random(2));
|
||||
}
|
||||
$role->save();
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
$this->assignRolePermissions($role, $permissions);
|
||||
|
||||
Activity::add(ActivityType::ROLE_CREATE, $role);
|
||||
|
||||
$this->permissionService->buildJointPermissionForRole($role);
|
||||
return $role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing role.
|
||||
* Ensure Admin role always have core permissions.
|
||||
* @param $roleId
|
||||
* @param $roleData
|
||||
* @throws PermissionsException
|
||||
*/
|
||||
public function updateRole($roleId, array $roleData)
|
||||
public function updateRole($roleId, $roleData)
|
||||
{
|
||||
$role = $this->getRoleById($roleId);
|
||||
$role = $this->role->findOrFail($roleId);
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
if ($role->system_name === 'admin') {
|
||||
@@ -85,27 +102,22 @@ class PermissionsRepo
|
||||
$this->assignRolePermissions($role, $permissions);
|
||||
|
||||
$role->fill($roleData);
|
||||
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
|
||||
$role->save();
|
||||
|
||||
Activity::add(ActivityType::ROLE_UPDATE, $role);
|
||||
$this->permissionService->buildJointPermissionForRole($role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a list of permission names to a role.
|
||||
* Assign an list of permission names to an role.
|
||||
* @param Role $role
|
||||
* @param array $permissionNameArray
|
||||
*/
|
||||
protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
|
||||
public function assignRolePermissions(Role $role, $permissionNameArray = [])
|
||||
{
|
||||
$permissions = [];
|
||||
$permissionNameArray = array_values($permissionNameArray);
|
||||
|
||||
if ($permissionNameArray) {
|
||||
$permissions = RolePermission::query()
|
||||
->whereIn('name', $permissionNameArray)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
if ($permissionNameArray && count($permissionNameArray) > 0) {
|
||||
$permissions = $this->permission->whereIn('name', $permissionNameArray)->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
$role->permissions()->sync($permissions);
|
||||
}
|
||||
|
||||
@@ -114,32 +126,30 @@ class PermissionsRepo
|
||||
* Check it's not an admin role or set as default before deleting.
|
||||
* If an migration Role ID is specified the users assign to the current role
|
||||
* will be added to the role of the specified id.
|
||||
*
|
||||
* @param $roleId
|
||||
* @param $migrateRoleId
|
||||
* @throws PermissionsException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function deleteRole($roleId, $migrateRoleId)
|
||||
{
|
||||
$role = $this->getRoleById($roleId);
|
||||
$role = $this->role->findOrFail($roleId);
|
||||
|
||||
// Prevent deleting admin role or default registration role.
|
||||
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
|
||||
throw new PermissionsException(trans('errors.role_system_cannot_be_deleted'));
|
||||
} elseif ($role->id === intval(setting('registration-role'))) {
|
||||
} else if ($role->id === intval(setting('registration-role'))) {
|
||||
throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
|
||||
}
|
||||
|
||||
if ($migrateRoleId) {
|
||||
$newRole = Role::query()->find($migrateRoleId);
|
||||
$newRole = $this->role->find($migrateRoleId);
|
||||
if ($newRole) {
|
||||
$users = $role->users()->pluck('id')->toArray();
|
||||
$users = $role->users->pluck('id')->toArray();
|
||||
$newRole->users()->sync($users);
|
||||
}
|
||||
}
|
||||
|
||||
$role->entityPermissions()->delete();
|
||||
$role->collapsedPermissions()->delete();
|
||||
Activity::add(ActivityType::ROLE_DELETE, $role);
|
||||
$this->permissionService->deleteJointPermissionsForRole($role);
|
||||
$role->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
<?php namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
*/
|
||||
class RolePermission extends Model
|
||||
{
|
||||
/**
|
||||
* The roles that belong to the permission.
|
||||
*/
|
||||
public function roles(): BelongsToMany
|
||||
public function roles()
|
||||
{
|
||||
return $this->belongsToMany(Role::class, 'permission_role', 'permission_id', 'role_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permission object by name.
|
||||
* @param $name
|
||||
* @return mixed
|
||||
*/
|
||||
public static function getByName(string $name): ?RolePermission
|
||||
public static function getByName($name)
|
||||
{
|
||||
return static::where('name', '=', $name)->first();
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
class SimpleEntityData
|
||||
{
|
||||
public int $id;
|
||||
public string $type;
|
||||
public ?int $book_id;
|
||||
public ?int $chapter_id;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Get all the roles in the system in a paginated format.
|
||||
*/
|
||||
class RolesAllPaginatedAndSorted
|
||||
{
|
||||
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
|
||||
{
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'created_at') {
|
||||
$sort = 'users.created_at';
|
||||
}
|
||||
|
||||
$query = Role::query()->select(['*'])
|
||||
->withCount(['users', 'permissions'])
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
if ($listOptions->getSearch()) {
|
||||
$term = '%' . $listOptions->getSearch() . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('display_name', 'like', $term)
|
||||
->orWhere('description', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate($count);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
|
||||
/**
|
||||
* Get asset created counts for the given user.
|
||||
*/
|
||||
class UserContentCounts
|
||||
{
|
||||
/**
|
||||
* @return array{pages: int, chapters: int, books: int, shelves: int}
|
||||
*/
|
||||
public function run(User $user): array
|
||||
{
|
||||
$createdBy = ['created_by' => $user->id];
|
||||
|
||||
return [
|
||||
'pages' => Page::visible()->where($createdBy)->count(),
|
||||
'chapters' => Chapter::visible()->where($createdBy)->count(),
|
||||
'books' => Book::visible()->where($createdBy)->count(),
|
||||
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
/**
|
||||
* Get the recently created content for the provided user.
|
||||
*/
|
||||
class UserRecentlyCreatedContent
|
||||
{
|
||||
/**
|
||||
* @return array{pages: Collection, chapters: Collection, books: Collection, shelves: Collection}
|
||||
*/
|
||||
public function run(User $user, int $count): array
|
||||
{
|
||||
$query = function (Builder $query) use ($user, $count) {
|
||||
return $query->orderBy('created_at', 'desc')
|
||||
->where('created_by', '=', $user->id)
|
||||
->take($count)
|
||||
->get();
|
||||
};
|
||||
|
||||
return [
|
||||
'pages' => $query(Page::visible()->where('draft', '=', false)),
|
||||
'chapters' => $query(Chapter::visible()),
|
||||
'books' => $query(Book::visible()),
|
||||
'shelves' => $query(Bookshelf::visible()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Get all the users with their permissions in a paginated format.
|
||||
* Note: Due to the use of email search this should only be used when
|
||||
* user is assumed to be trusted. (Admin users).
|
||||
* Email search can be abused to extract email addresses.
|
||||
*/
|
||||
class UsersAllPaginatedAndSorted
|
||||
{
|
||||
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
|
||||
{
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'created_at') {
|
||||
$sort = 'users.created_at';
|
||||
}
|
||||
|
||||
$query = User::query()->select(['*'])
|
||||
->scopes(['withLastActivityAt'])
|
||||
->with(['roles', 'avatar'])
|
||||
->withCount('mfaValues')
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
if ($listOptions->getSearch()) {
|
||||
$term = '%' . $listOptions->getSearch() . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('name', 'like', $term)
|
||||
->orWhere('email', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate($count);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,52 @@
|
||||
<?php
|
||||
<?php namespace BookStack\Auth;
|
||||
|
||||
namespace BookStack\Auth;
|
||||
|
||||
use BookStack\Auth\Permissions\CollapsedPermission;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\Permissions\RolePermission;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Class Role.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $display_name
|
||||
* @property string $description
|
||||
* @property string $external_auth_id
|
||||
* @property string $system_name
|
||||
* @property bool $mfa_enforced
|
||||
* @property Collection $users
|
||||
* Class Role
|
||||
* @property string $display_name
|
||||
* @property string $description
|
||||
* @property string $external_auth_id
|
||||
* @package BookStack\Auth
|
||||
*/
|
||||
class Role extends Model implements Loggable
|
||||
class Role extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['display_name', 'description', 'external_auth_id'];
|
||||
|
||||
protected $hidden = ['pivot'];
|
||||
|
||||
/**
|
||||
* The roles that belong to the role.
|
||||
*/
|
||||
public function users(): BelongsToMany
|
||||
public function users()
|
||||
{
|
||||
return $this->belongsToMany(User::class)->orderBy('name', 'asc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all related JointPermissions.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function jointPermissions()
|
||||
{
|
||||
return $this->hasMany(JointPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The RolePermissions that belong to the role.
|
||||
*/
|
||||
public function permissions(): BelongsToMany
|
||||
public function permissions()
|
||||
{
|
||||
return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions assigned to this role.
|
||||
*/
|
||||
public function entityPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntityPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all related entity collapsed permissions.
|
||||
*/
|
||||
public function collapsedPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CollapsedPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this role has a permission.
|
||||
* @param $permissionName
|
||||
* @return bool
|
||||
*/
|
||||
public function hasPermission(string $permissionName): bool
|
||||
public function hasPermission($permissionName)
|
||||
{
|
||||
$permissions = $this->getRelationValue('permissions');
|
||||
foreach ($permissions as $permission) {
|
||||
@@ -74,12 +54,12 @@ class Role extends Model implements Loggable
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a permission to this role.
|
||||
* @param RolePermission $permission
|
||||
*/
|
||||
public function attachPermission(RolePermission $permission)
|
||||
{
|
||||
@@ -88,6 +68,7 @@ class Role extends Model implements Loggable
|
||||
|
||||
/**
|
||||
* Detach a single permission from this role.
|
||||
* @param RolePermission $permission
|
||||
*/
|
||||
public function detachPermission(RolePermission $permission)
|
||||
{
|
||||
@@ -95,26 +76,40 @@ class Role extends Model implements Loggable
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role of the specified display name.
|
||||
* Get the role object for the specified role.
|
||||
* @param $roleName
|
||||
* @return Role
|
||||
*/
|
||||
public static function getRole(string $displayName): ?self
|
||||
public static function getRole($roleName)
|
||||
{
|
||||
return static::query()->where('display_name', '=', $displayName)->first();
|
||||
return static::query()->where('name', '=', $roleName)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role object for the specified system role.
|
||||
* @param $roleName
|
||||
* @return Role
|
||||
*/
|
||||
public static function getSystemRole(string $systemName): ?self
|
||||
public static function getSystemRole($roleName)
|
||||
{
|
||||
return static::query()->where('system_name', '=', $systemName)->first();
|
||||
return static::query()->where('system_name', '=', $roleName)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* Get all visible roles
|
||||
* @return mixed
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
public static function visible()
|
||||
{
|
||||
return "({$this->id}) {$this->display_name}";
|
||||
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles that can be restricted.
|
||||
* @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public static function restrictable()
|
||||
{
|
||||
return static::query()->where('system_name', '!=', 'admin')->get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,14 @@
|
||||
<?php
|
||||
<?php namespace BookStack\Auth;
|
||||
|
||||
namespace BookStack\Auth;
|
||||
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
|
||||
/**
|
||||
* Class SocialAccount.
|
||||
*
|
||||
* @property string $driver
|
||||
* @property User $user
|
||||
*/
|
||||
class SocialAccount extends Model implements Loggable
|
||||
class SocialAccount extends Model
|
||||
{
|
||||
|
||||
protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "{$this->driver}; {$this->user->logDescriptor()}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,149 +1,119 @@
|
||||
<?php
|
||||
<?php namespace BookStack\Auth;
|
||||
|
||||
namespace BookStack\Auth;
|
||||
|
||||
use BookStack\Actions\Favourite;
|
||||
use BookStack\Api\ApiToken;
|
||||
use BookStack\Auth\Access\Mfa\MfaValue;
|
||||
use BookStack\Auth\Permissions\CollapsedPermission;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Interfaces\Sluggable;
|
||||
use BookStack\Model;
|
||||
use BookStack\Notifications\ResetPassword;
|
||||
use BookStack\Uploads\Image;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Auth\Authenticatable;
|
||||
use Illuminate\Auth\Passwords\CanResetPassword;
|
||||
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
|
||||
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Class User.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $slug
|
||||
* @property string $email
|
||||
* @property string $password
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property bool $email_confirmed
|
||||
* @property int $image_id
|
||||
* @property string $external_auth_id
|
||||
* @property string $system_name
|
||||
* @property Collection $roles
|
||||
* @property Collection $mfaValues
|
||||
* Class User
|
||||
* @package BookStack\Auth
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property string $password
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property bool $email_confirmed
|
||||
* @property int $image_id
|
||||
* @property string $external_auth_id
|
||||
* @property string $system_name
|
||||
*/
|
||||
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable
|
||||
class User extends Model implements AuthenticatableContract, CanResetPasswordContract
|
||||
{
|
||||
use HasFactory;
|
||||
use Authenticatable;
|
||||
use CanResetPassword;
|
||||
use Notifiable;
|
||||
use Authenticatable, CanResetPassword, Notifiable;
|
||||
|
||||
/**
|
||||
* The database table used by the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'users';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = ['name', 'email'];
|
||||
|
||||
protected $casts = ['last_activity_at' => 'datetime'];
|
||||
|
||||
/**
|
||||
* The attributes excluded from the model's JSON form.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
|
||||
'created_at', 'updated_at', 'image_id', 'roles', 'avatar', 'user_id',
|
||||
];
|
||||
protected $hidden = ['password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email'];
|
||||
|
||||
/**
|
||||
* This holds the user's permissions when loaded.
|
||||
* @var array
|
||||
*/
|
||||
protected ?Collection $permissions;
|
||||
|
||||
/**
|
||||
* This holds the user's avatar URL when loaded to prevent re-calculating within the same request.
|
||||
*/
|
||||
protected string $avatarUrl = '';
|
||||
protected $permissions;
|
||||
|
||||
/**
|
||||
* This holds the default user when loaded.
|
||||
*
|
||||
* @var null|User
|
||||
*/
|
||||
protected static ?User $defaultUser = null;
|
||||
protected static $defaultUser = null;
|
||||
|
||||
/**
|
||||
* Returns the default public user.
|
||||
* @return User
|
||||
*/
|
||||
public static function getDefault(): self
|
||||
public static function getDefault()
|
||||
{
|
||||
if (!is_null(static::$defaultUser)) {
|
||||
return static::$defaultUser;
|
||||
}
|
||||
|
||||
static::$defaultUser = static::query()->where('system_name', '=', 'public')->first();
|
||||
|
||||
|
||||
static::$defaultUser = static::where('system_name', '=', 'public')->first();
|
||||
return static::$defaultUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is the default public user.
|
||||
* @return bool
|
||||
*/
|
||||
public function isDefault(): bool
|
||||
public function isDefault()
|
||||
{
|
||||
return $this->system_name === 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* The roles that belong to the user.
|
||||
*
|
||||
* @return BelongsToMany
|
||||
*/
|
||||
public function roles()
|
||||
{
|
||||
if ($this->id === 0) {
|
||||
return;
|
||||
return ;
|
||||
}
|
||||
|
||||
return $this->belongsToMany(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has a role.
|
||||
* @param $role
|
||||
* @return mixed
|
||||
*/
|
||||
public function hasRole($roleId): bool
|
||||
public function hasRole($role)
|
||||
{
|
||||
return $this->roles->pluck('id')->contains($roleId);
|
||||
return $this->roles->pluck('name')->contains($role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has a role.
|
||||
* @param $role
|
||||
* @return mixed
|
||||
*/
|
||||
public function hasSystemRole(string $roleSystemName): bool
|
||||
public function hasSystemRole($role)
|
||||
{
|
||||
return $this->roles->pluck('system_name')->contains($roleSystemName);
|
||||
return $this->roles->pluck('system_name')->contains($role);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,53 +121,46 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
*/
|
||||
public function attachDefaultRole(): void
|
||||
{
|
||||
$roleId = intval(setting('registration-role'));
|
||||
$roleId = setting('registration-role');
|
||||
if ($roleId && $this->roles()->where('id', '=', $roleId)->count() === 0) {
|
||||
$this->roles()->attach($roleId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has a particular permission.
|
||||
* Get all permissions belonging to a the current user.
|
||||
* @param bool $cache
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
|
||||
*/
|
||||
public function can(string $permissionName): bool
|
||||
public function permissions($cache = true)
|
||||
{
|
||||
if (isset($this->permissions) && $cache) {
|
||||
return $this->permissions;
|
||||
}
|
||||
$this->load('roles.permissions');
|
||||
$permissions = $this->roles->map(function ($role) {
|
||||
return $role->permissions;
|
||||
})->flatten()->unique();
|
||||
$this->permissions = $permissions;
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has a particular permission.
|
||||
* @param $permissionName
|
||||
* @return bool
|
||||
*/
|
||||
public function can($permissionName)
|
||||
{
|
||||
if ($this->email === 'guest') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->permissions()->contains($permissionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions belonging to the current user.
|
||||
*/
|
||||
protected function permissions(): Collection
|
||||
{
|
||||
if (isset($this->permissions)) {
|
||||
return $this->permissions;
|
||||
}
|
||||
|
||||
$this->permissions = $this->newQuery()->getConnection()->table('role_user', 'ru')
|
||||
->select('role_permissions.name as name')->distinct()
|
||||
->leftJoin('permission_role', 'ru.role_id', '=', 'permission_role.role_id')
|
||||
->leftJoin('role_permissions', 'permission_role.permission_id', '=', 'role_permissions.id')
|
||||
->where('ru.user_id', '=', $this->id)
|
||||
->pluck('name');
|
||||
|
||||
return $this->permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any cached permissions on this instance.
|
||||
*/
|
||||
public function clearPermissionCache()
|
||||
{
|
||||
$this->permissions = null;
|
||||
return $this->permissions()->pluck('name')->contains($permissionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a role to this user.
|
||||
* @param Role $role
|
||||
*/
|
||||
public function attachRole(Role $role)
|
||||
{
|
||||
@@ -206,8 +169,9 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
|
||||
/**
|
||||
* Get the social account associated with this user.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function socialAccounts(): HasMany
|
||||
public function socialAccounts()
|
||||
{
|
||||
return $this->hasMany(SocialAccount::class);
|
||||
}
|
||||
@@ -215,9 +179,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
/**
|
||||
* Check if the user has a social account,
|
||||
* If a driver is passed it checks for that single account type.
|
||||
*
|
||||
* @param bool|string $socialDriver
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasSocialAccount($socialDriver = false)
|
||||
@@ -230,9 +192,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a URL to the user's avatar.
|
||||
* Returns the user's avatar,
|
||||
* @param int $size
|
||||
* @return string
|
||||
*/
|
||||
public function getAvatar(int $size = 50): string
|
||||
public function getAvatar($size = 50)
|
||||
{
|
||||
$default = url('/user_avatar.png');
|
||||
$imageId = $this->image_id;
|
||||
@@ -240,25 +204,19 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (!empty($this->avatarUrl)) {
|
||||
return $this->avatarUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
|
||||
} catch (Exception $err) {
|
||||
} catch (\Exception $err) {
|
||||
$avatar = $default;
|
||||
}
|
||||
|
||||
$this->avatarUrl = $avatar;
|
||||
|
||||
return $avatar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the avatar for the user.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function avatar(): BelongsTo
|
||||
public function avatar()
|
||||
{
|
||||
return $this->belongsTo(Image::class, 'image_id');
|
||||
}
|
||||
@@ -271,58 +229,12 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
return $this->hasMany(ApiToken::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the favourite instances for this user.
|
||||
*/
|
||||
public function favourites(): HasMany
|
||||
{
|
||||
return $this->hasMany(Favourite::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the MFA values belonging to this use.
|
||||
*/
|
||||
public function mfaValues(): HasMany
|
||||
{
|
||||
return $this->hasMany(MfaValue::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last activity time for this user.
|
||||
*/
|
||||
public function scopeWithLastActivityAt(Builder $query)
|
||||
{
|
||||
$query->addSelect(['activities.created_at as last_activity_at'])
|
||||
->leftJoinSub(function (\Illuminate\Database\Query\Builder $query) {
|
||||
$query->from('activities')->select('user_id')
|
||||
->selectRaw('max(created_at) as created_at')
|
||||
->groupBy('user_id');
|
||||
}, 'activities', 'users.id', '=', 'activities.user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions assigned to this specific user.
|
||||
*/
|
||||
public function entityPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntityPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all related entity collapsed permissions.
|
||||
*/
|
||||
public function collapsedPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CollapsedPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the url for editing this user.
|
||||
*/
|
||||
public function getEditUrl(string $path = ''): string
|
||||
{
|
||||
$uri = '/settings/users/' . $this->id . '/' . trim($path, '/');
|
||||
|
||||
return url(rtrim($uri, '/'));
|
||||
}
|
||||
|
||||
@@ -331,13 +243,15 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
*/
|
||||
public function getProfileUrl(): string
|
||||
{
|
||||
return url('/user/' . $this->slug);
|
||||
return url('/user/' . $this->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a shortened version of the user's name.
|
||||
* @param int $chars
|
||||
* @return string
|
||||
*/
|
||||
public function getShortName(int $chars = 8): string
|
||||
public function getShortName($chars = 8)
|
||||
{
|
||||
if (mb_strlen($this->name) <= $chars) {
|
||||
return $this->name;
|
||||
@@ -353,31 +267,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
|
||||
/**
|
||||
* Send the password reset notification.
|
||||
*
|
||||
* @param string $token
|
||||
*
|
||||
* @param string $token
|
||||
* @return void
|
||||
*/
|
||||
public function sendPasswordResetNotification($token)
|
||||
{
|
||||
$this->notify(new ResetPassword($token));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function refreshSlug(): string
|
||||
{
|
||||
$this->slug = app(SlugGenerator::class)->generate($this);
|
||||
|
||||
return $this->slug;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
<?php
|
||||
<?php namespace BookStack\Auth;
|
||||
|
||||
namespace BookStack\Auth;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\UserInviteService;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use Activity;
|
||||
use BookStack\Entities\Book;
|
||||
use BookStack\Entities\Bookshelf;
|
||||
use BookStack\Entities\Chapter;
|
||||
use BookStack\Entities\Page;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\UserAvatars;
|
||||
use BookStack\Uploads\Image;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Images;
|
||||
use Log;
|
||||
|
||||
class UserRepo
|
||||
{
|
||||
protected UserAvatars $userAvatar;
|
||||
protected UserInviteService $inviteService;
|
||||
|
||||
protected $user;
|
||||
protected $role;
|
||||
|
||||
/**
|
||||
* UserRepo constructor.
|
||||
*/
|
||||
public function __construct(UserAvatars $userAvatar, UserInviteService $inviteService)
|
||||
public function __construct(User $user, Role $role)
|
||||
{
|
||||
$this->userAvatar = $userAvatar;
|
||||
$this->inviteService = $inviteService;
|
||||
$this->user = $user;
|
||||
$this->role = $role;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,208 +33,100 @@ class UserRepo
|
||||
*/
|
||||
public function getByEmail(string $email): ?User
|
||||
{
|
||||
return User::query()->where('email', '=', $email)->first();
|
||||
return $this->user->where('email', '=', $email)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user by their ID.
|
||||
* @param int $id
|
||||
* @return User
|
||||
*/
|
||||
public function getById(int $id): User
|
||||
public function getById($id)
|
||||
{
|
||||
return User::query()->findOrFail($id);
|
||||
return $this->user->newQuery()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user by their slug.
|
||||
* Get all the users with their permissions.
|
||||
* @return Builder|static
|
||||
*/
|
||||
public function getBySlug(string $slug): User
|
||||
public function getAllUsers()
|
||||
{
|
||||
return User::query()->where('slug', '=', $slug)->firstOrFail();
|
||||
return $this->user->with('roles', 'avatar')->orderBy('name', 'asc')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new basic instance of user with the given pre-validated data.
|
||||
*
|
||||
* @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data
|
||||
* Get all the users with their permissions in a paginated format.
|
||||
* @param int $count
|
||||
* @param $sortData
|
||||
* @return Builder|static
|
||||
*/
|
||||
public function createWithoutActivity(array $data, bool $emailConfirmed = false): User
|
||||
public function getAllUsersPaginatedAndSorted($count, $sortData)
|
||||
{
|
||||
$user = new User();
|
||||
$user->name = $data['name'];
|
||||
$user->email = $data['email'];
|
||||
$user->password = Hash::make(empty($data['password']) ? Str::random(32) : $data['password']);
|
||||
$user->email_confirmed = $emailConfirmed;
|
||||
$user->external_auth_id = $data['external_auth_id'] ?? '';
|
||||
$query = $this->user->with('roles', 'avatar')->orderBy($sortData['sort'], $sortData['order']);
|
||||
|
||||
$user->refreshSlug();
|
||||
$user->save();
|
||||
|
||||
if (!empty($data['language'])) {
|
||||
setting()->putUser($user, 'language', $data['language']);
|
||||
if ($sortData['search']) {
|
||||
$term = '%' . $sortData['search'] . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('name', 'like', $term)
|
||||
->orWhere('email', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
if (isset($data['roles'])) {
|
||||
$this->setUserRoles($user, $data['roles']);
|
||||
}
|
||||
return $query->paginate($count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user and attaches a role to them.
|
||||
*/
|
||||
public function registerNew(array $data, bool $emailConfirmed = false): User
|
||||
{
|
||||
$user = $this->create($data, $emailConfirmed);
|
||||
$user->attachDefaultRole();
|
||||
$this->downloadAndAssignUserAvatar($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* As per "createWithoutActivity" but records a "create" activity.
|
||||
*
|
||||
* @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data
|
||||
* Assign a user to a system-level role.
|
||||
* @param User $user
|
||||
* @param $systemRoleName
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function create(array $data, bool $sendInvite = false): User
|
||||
public function attachSystemRole(User $user, $systemRoleName)
|
||||
{
|
||||
$user = $this->createWithoutActivity($data, true);
|
||||
|
||||
if ($sendInvite) {
|
||||
$this->inviteService->sendInvitation($user);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::USER_CREATE, $user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given user with the given data.
|
||||
*
|
||||
* @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string} $data
|
||||
*
|
||||
* @throws UserUpdateException
|
||||
*/
|
||||
public function update(User $user, array $data, bool $manageUsersAllowed): User
|
||||
{
|
||||
if (!empty($data['name'])) {
|
||||
$user->name = $data['name'];
|
||||
$user->refreshSlug();
|
||||
}
|
||||
|
||||
if (!empty($data['email']) && $manageUsersAllowed) {
|
||||
$user->email = $data['email'];
|
||||
}
|
||||
|
||||
if (!empty($data['external_auth_id']) && $manageUsersAllowed) {
|
||||
$user->external_auth_id = $data['external_auth_id'];
|
||||
}
|
||||
|
||||
if (isset($data['roles']) && $manageUsersAllowed) {
|
||||
$this->setUserRoles($user, $data['roles']);
|
||||
}
|
||||
|
||||
if (!empty($data['password'])) {
|
||||
$user->password = Hash::make($data['password']);
|
||||
}
|
||||
|
||||
if (!empty($data['language'])) {
|
||||
setting()->putUser($user, 'language', $data['language']);
|
||||
}
|
||||
|
||||
$user->save();
|
||||
Activity::add(ActivityType::USER_UPDATE, $user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given user from storage, Delete all related content.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(User $user, ?int $newOwnerId = null)
|
||||
{
|
||||
$this->ensureDeletable($user);
|
||||
|
||||
$user->socialAccounts()->delete();
|
||||
$user->apiTokens()->delete();
|
||||
$user->favourites()->delete();
|
||||
$user->mfaValues()->delete();
|
||||
$user->collapsedPermissions()->delete();
|
||||
$user->entityPermissions()->delete();
|
||||
$user->delete();
|
||||
|
||||
// Delete user profile images
|
||||
$this->userAvatar->destroyAllForUser($user);
|
||||
|
||||
// Delete related activities
|
||||
setting()->deleteUserSettings($user->id);
|
||||
|
||||
if (!empty($newOwnerId)) {
|
||||
$newOwner = User::query()->find($newOwnerId);
|
||||
if (!is_null($newOwner)) {
|
||||
$this->migrateOwnership($user, $newOwner);
|
||||
}
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::USER_DELETE, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotifyException
|
||||
*/
|
||||
protected function ensureDeletable(User $user): void
|
||||
{
|
||||
if ($this->isOnlyAdmin($user)) {
|
||||
throw new NotifyException(trans('errors.users_cannot_delete_only_admin'), $user->getEditUrl());
|
||||
}
|
||||
|
||||
if ($user->system_name === 'public') {
|
||||
throw new NotifyException(trans('errors.users_cannot_delete_guest'), $user->getEditUrl());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate ownership of items in the system from one user to another.
|
||||
*/
|
||||
protected function migrateOwnership(User $fromUser, User $toUser)
|
||||
{
|
||||
$entities = (new EntityProvider())->all();
|
||||
foreach ($entities as $instance) {
|
||||
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
|
||||
->update(['owned_by' => $toUser->id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an avatar image for a user and set it as their avatar.
|
||||
* Returns early if avatars disabled or not set in config.
|
||||
*/
|
||||
protected function downloadAndAssignUserAvatar(User $user): void
|
||||
{
|
||||
try {
|
||||
$this->userAvatar->fetchAndAssignToUser($user);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to save user avatar image');
|
||||
$role = $this->role->newQuery()->where('system_name', '=', $systemRoleName)->first();
|
||||
if ($role === null) {
|
||||
throw new NotFoundException("Role '{$systemRoleName}' not found");
|
||||
}
|
||||
$user->attachRole($role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the give user is the only admin.
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
protected function isOnlyAdmin(User $user): bool
|
||||
public function isOnlyAdmin(User $user)
|
||||
{
|
||||
if (!$user->hasSystemRole('admin')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$adminRole = Role::getSystemRole('admin');
|
||||
if ($adminRole->users()->count() > 1) {
|
||||
$adminRole = $this->role->getSystemRole('admin');
|
||||
if ($adminRole->users->count() > 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the assigned user roles via an array of role IDs.
|
||||
*
|
||||
* @param User $user
|
||||
* @param array $roles
|
||||
* @throws UserUpdateException
|
||||
*/
|
||||
protected function setUserRoles(User $user, array $roles)
|
||||
public function setUserRoles(User $user, array $roles)
|
||||
{
|
||||
if ($this->demotingLastAdmin($user, $roles)) {
|
||||
throw new UserUpdateException(trans('errors.role_cannot_remove_only_admin'), $user->getEditUrl());
|
||||
@@ -246,11 +138,14 @@ class UserRepo
|
||||
/**
|
||||
* Check if the given user is the last admin and their new roles no longer
|
||||
* contains the admin role.
|
||||
* @param User $user
|
||||
* @param array $newRoles
|
||||
* @return bool
|
||||
*/
|
||||
protected function demotingLastAdmin(User $user, array $newRoles): bool
|
||||
protected function demotingLastAdmin(User $user, array $newRoles) : bool
|
||||
{
|
||||
if ($this->isOnlyAdmin($user)) {
|
||||
$adminRole = Role::getSystemRole('admin');
|
||||
$adminRole = $this->role->getSystemRole('admin');
|
||||
if (!in_array(strval($adminRole->id), $newRoles)) {
|
||||
return true;
|
||||
}
|
||||
@@ -258,4 +153,114 @@ class UserRepo
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new basic instance of user.
|
||||
*/
|
||||
public function create(array $data, bool $emailConfirmed = false): User
|
||||
{
|
||||
return $this->user->forceCreate([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => bcrypt($data['password']),
|
||||
'email_confirmed' => $emailConfirmed,
|
||||
'external_auth_id' => $data['external_auth_id'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given user from storage, Delete all related content.
|
||||
* @param User $user
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(User $user)
|
||||
{
|
||||
$user->socialAccounts()->delete();
|
||||
$user->apiTokens()->delete();
|
||||
$user->delete();
|
||||
|
||||
// Delete user profile images
|
||||
$profileImages = Image::where('type', '=', 'user')->where('uploaded_to', '=', $user->id)->get();
|
||||
foreach ($profileImages as $image) {
|
||||
Images::destroy($image);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest activity for a user.
|
||||
* @param User $user
|
||||
* @param int $count
|
||||
* @param int $page
|
||||
* @return array
|
||||
*/
|
||||
public function getActivity(User $user, $count = 20, $page = 0)
|
||||
{
|
||||
return Activity::userActivity($user, $count, $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recently created content for this given user.
|
||||
*/
|
||||
public function getRecentlyCreated(User $user, int $count = 20): array
|
||||
{
|
||||
$query = function (Builder $query) use ($user, $count) {
|
||||
return $query->orderBy('created_at', 'desc')
|
||||
->where('created_by', '=', $user->id)
|
||||
->take($count)
|
||||
->get();
|
||||
};
|
||||
|
||||
return [
|
||||
'pages' => $query(Page::visible()->where('draft', '=', false)),
|
||||
'chapters' => $query(Chapter::visible()),
|
||||
'books' => $query(Book::visible()),
|
||||
'shelves' => $query(Bookshelf::visible()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset created counts for the give user.
|
||||
*/
|
||||
public function getAssetCounts(User $user): array
|
||||
{
|
||||
$createdBy = ['created_by' => $user->id];
|
||||
return [
|
||||
'pages' => Page::visible()->where($createdBy)->count(),
|
||||
'chapters' => Chapter::visible()->where($createdBy)->count(),
|
||||
'books' => Book::visible()->where($createdBy)->count(),
|
||||
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles in the system that are assignable to a user.
|
||||
* @return mixed
|
||||
*/
|
||||
public function getAllRoles()
|
||||
{
|
||||
return $this->role->newQuery()->orderBy('name', 'asc')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an avatar image for a user and set it as their avatar.
|
||||
* Returns early if avatars disabled or not set in config.
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
public function downloadAndAssignUserAvatar(User $user)
|
||||
{
|
||||
if (!Images::avatarFetchEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$avatar = Images::saveUserAvatar($user);
|
||||
$user->avatar()->associate($avatar);
|
||||
$user->save();
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to save user avatar image');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,6 @@ return [
|
||||
'max_item_count' => env('API_MAX_ITEM_COUNT', 500),
|
||||
|
||||
// The number of API requests that can be made per minute by a single user.
|
||||
'requests_per_minute' => env('API_REQUESTS_PER_MIN', 180),
|
||||
'requests_per_minute' => env('API_REQUESTS_PER_MIN', 180)
|
||||
|
||||
];
|
||||
|
||||
153
app/Config/app.php
Normal file → Executable file
153
app/Config/app.php
Normal file → Executable file
@@ -19,31 +19,23 @@ return [
|
||||
// private configuration variables so should remain disabled in public.
|
||||
'debug' => env('APP_DEBUG', false),
|
||||
|
||||
// Set the default view type for various lists. Can be overridden by user preferences.
|
||||
// These will be used for public viewers and users that have not set a preference.
|
||||
'views' => [
|
||||
'books' => env('APP_VIEWS_BOOKS', 'list'),
|
||||
'bookshelves' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
|
||||
],
|
||||
|
||||
// The number of revisions to keep in the database.
|
||||
// Once this limit is reached older revisions will be deleted.
|
||||
// If set to false then a limit will not be enforced.
|
||||
'revision_limit' => env('REVISION_LIMIT', 100),
|
||||
|
||||
// The number of days that content will remain in the recycle bin before
|
||||
// being considered for auto-removal. It is not a guarantee that content will
|
||||
// be removed after this time.
|
||||
// Set to 0 for no recycle bin functionality.
|
||||
// Set to -1 for unlimited recycle bin lifetime.
|
||||
'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30),
|
||||
|
||||
// The limit for all uploaded files, including images and attachments in MB.
|
||||
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
|
||||
'revision_limit' => env('REVISION_LIMIT', 50),
|
||||
|
||||
// Allow <script> tags to entered within page content.
|
||||
// <script> tags are escaped by default.
|
||||
// Even when overridden the WYSIWYG editor may still escape script content.
|
||||
'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
|
||||
|
||||
// Allow server-side fetches to be performed to potentially unknown
|
||||
// and user-provided locations. Primarily used in exports when loading
|
||||
// in externally referenced assets.
|
||||
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
|
||||
|
||||
// Override the default behaviour for allowing crawlers to crawl the instance.
|
||||
// May be ignored if view has be overridden or modified.
|
||||
// Defaults to null since, if not set, 'app-public' status used instead.
|
||||
@@ -53,21 +45,6 @@ return [
|
||||
// and used by BookStack in URL generation.
|
||||
'url' => env('APP_URL', '') === 'http://bookstack.dev' ? '' : env('APP_URL', ''),
|
||||
|
||||
// A list of hosts that BookStack can be iframed within.
|
||||
// Space separated if multiple. BookStack host domain is auto-inferred.
|
||||
'iframe_hosts' => env('ALLOWED_IFRAME_HOSTS', null),
|
||||
|
||||
// A list of sources/hostnames that can be loaded within iframes within BookStack.
|
||||
// Space separated if multiple. BookStack host domain is auto-inferred.
|
||||
// Can be set to a lone "*" to allow all sources for iframe content (Not advised).
|
||||
// Defaults to a set of common services.
|
||||
// Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
|
||||
'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'),
|
||||
|
||||
// Alter the precision of IP addresses stored by BookStack.
|
||||
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
|
||||
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
|
||||
|
||||
// Application timezone for back-end date functions.
|
||||
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||
|
||||
@@ -75,7 +52,7 @@ return [
|
||||
'locale' => env('APP_LANG', 'en'),
|
||||
|
||||
// Locales available
|
||||
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ka', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
|
||||
'locales' => ['en', 'ar', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fr', 'hu', 'nl', 'pt_BR', 'sk', 'cs', 'sv', 'ko', 'ja', 'pl', 'it', 'ru', 'uk', 'zh_CN', 'zh_TW', 'tr'],
|
||||
|
||||
// Application Fallback Locale
|
||||
'fallback_locale' => 'en',
|
||||
@@ -114,8 +91,6 @@ return [
|
||||
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
|
||||
Illuminate\Hashing\HashServiceProvider::class,
|
||||
Illuminate\Mail\MailServiceProvider::class,
|
||||
Illuminate\Notifications\NotificationServiceProvider::class,
|
||||
Illuminate\Pagination\PaginationServiceProvider::class,
|
||||
Illuminate\Pipeline\PipelineServiceProvider::class,
|
||||
Illuminate\Queue\QueueServiceProvider::class,
|
||||
Illuminate\Redis\RedisServiceProvider::class,
|
||||
@@ -123,22 +98,25 @@ return [
|
||||
Illuminate\Session\SessionServiceProvider::class,
|
||||
Illuminate\Validation\ValidationServiceProvider::class,
|
||||
Illuminate\View\ViewServiceProvider::class,
|
||||
|
||||
// Third party service providers
|
||||
Barryvdh\DomPDF\ServiceProvider::class,
|
||||
Barryvdh\Snappy\ServiceProvider::class,
|
||||
Intervention\Image\ImageServiceProvider::class,
|
||||
Illuminate\Notifications\NotificationServiceProvider::class,
|
||||
SocialiteProviders\Manager\ServiceProvider::class,
|
||||
|
||||
// Third party service providers
|
||||
Intervention\Image\ImageServiceProvider::class,
|
||||
Barryvdh\DomPDF\ServiceProvider::class,
|
||||
Barryvdh\Snappy\ServiceProvider::class,
|
||||
|
||||
// BookStack replacement service providers (Extends Laravel)
|
||||
BookStack\Providers\PaginationServiceProvider::class,
|
||||
BookStack\Providers\TranslationServiceProvider::class,
|
||||
|
||||
// BookStack custom service providers
|
||||
BookStack\Providers\ThemeServiceProvider::class,
|
||||
BookStack\Providers\AppServiceProvider::class,
|
||||
BookStack\Providers\AuthServiceProvider::class,
|
||||
BookStack\Providers\AppServiceProvider::class,
|
||||
BookStack\Providers\BroadcastServiceProvider::class,
|
||||
BookStack\Providers\EventServiceProvider::class,
|
||||
BookStack\Providers\RouteServiceProvider::class,
|
||||
BookStack\Providers\TranslationServiceProvider::class,
|
||||
BookStack\Providers\ValidationRuleServiceProvider::class,
|
||||
BookStack\Providers\ViewTweaksServiceProvider::class,
|
||||
BookStack\Providers\CustomFacadeProvider::class,
|
||||
],
|
||||
|
||||
/*
|
||||
@@ -154,54 +132,57 @@ return [
|
||||
|
||||
// Class aliases, Registered on application start
|
||||
'aliases' => [
|
||||
// Laravel
|
||||
'App' => Illuminate\Support\Facades\App::class,
|
||||
'Arr' => Illuminate\Support\Arr::class,
|
||||
'Artisan' => Illuminate\Support\Facades\Artisan::class,
|
||||
'Auth' => Illuminate\Support\Facades\Auth::class,
|
||||
'Blade' => Illuminate\Support\Facades\Blade::class,
|
||||
'Bus' => Illuminate\Support\Facades\Bus::class,
|
||||
'Cache' => Illuminate\Support\Facades\Cache::class,
|
||||
'Config' => Illuminate\Support\Facades\Config::class,
|
||||
'Cookie' => Illuminate\Support\Facades\Cookie::class,
|
||||
'Crypt' => Illuminate\Support\Facades\Crypt::class,
|
||||
'Date' => Illuminate\Support\Facades\Date::class,
|
||||
'DB' => Illuminate\Support\Facades\DB::class,
|
||||
'Eloquent' => Illuminate\Database\Eloquent\Model::class,
|
||||
'Event' => Illuminate\Support\Facades\Event::class,
|
||||
'File' => Illuminate\Support\Facades\File::class,
|
||||
'Gate' => Illuminate\Support\Facades\Gate::class,
|
||||
'Hash' => Illuminate\Support\Facades\Hash::class,
|
||||
'Http' => Illuminate\Support\Facades\Http::class,
|
||||
'Lang' => Illuminate\Support\Facades\Lang::class,
|
||||
'Log' => Illuminate\Support\Facades\Log::class,
|
||||
'Mail' => Illuminate\Support\Facades\Mail::class,
|
||||
'Notification' => Illuminate\Support\Facades\Notification::class,
|
||||
'Password' => Illuminate\Support\Facades\Password::class,
|
||||
'Queue' => Illuminate\Support\Facades\Queue::class,
|
||||
'RateLimiter' => Illuminate\Support\Facades\RateLimiter::class,
|
||||
'Redirect' => Illuminate\Support\Facades\Redirect::class,
|
||||
// 'Redis' => Illuminate\Support\Facades\Redis::class,
|
||||
'Request' => Illuminate\Support\Facades\Request::class,
|
||||
'Response' => Illuminate\Support\Facades\Response::class,
|
||||
'Route' => Illuminate\Support\Facades\Route::class,
|
||||
'Schema' => Illuminate\Support\Facades\Schema::class,
|
||||
'Session' => Illuminate\Support\Facades\Session::class,
|
||||
'Storage' => Illuminate\Support\Facades\Storage::class,
|
||||
'Str' => Illuminate\Support\Str::class,
|
||||
'URL' => Illuminate\Support\Facades\URL::class,
|
||||
'Validator' => Illuminate\Support\Facades\Validator::class,
|
||||
'View' => Illuminate\Support\Facades\View::class,
|
||||
|
||||
// Laravel Packages
|
||||
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
|
||||
// Laravel
|
||||
'App' => Illuminate\Support\Facades\App::class,
|
||||
'Arr' => Illuminate\Support\Arr::class,
|
||||
'Artisan' => Illuminate\Support\Facades\Artisan::class,
|
||||
'Auth' => Illuminate\Support\Facades\Auth::class,
|
||||
'Blade' => Illuminate\Support\Facades\Blade::class,
|
||||
'Bus' => Illuminate\Support\Facades\Bus::class,
|
||||
'Cache' => Illuminate\Support\Facades\Cache::class,
|
||||
'Config' => Illuminate\Support\Facades\Config::class,
|
||||
'Cookie' => Illuminate\Support\Facades\Cookie::class,
|
||||
'Crypt' => Illuminate\Support\Facades\Crypt::class,
|
||||
'DB' => Illuminate\Support\Facades\DB::class,
|
||||
'Eloquent' => Illuminate\Database\Eloquent\Model::class,
|
||||
'Event' => Illuminate\Support\Facades\Event::class,
|
||||
'File' => Illuminate\Support\Facades\File::class,
|
||||
'Hash' => Illuminate\Support\Facades\Hash::class,
|
||||
'Input' => Illuminate\Support\Facades\Input::class,
|
||||
'Inspiring' => Illuminate\Foundation\Inspiring::class,
|
||||
'Lang' => Illuminate\Support\Facades\Lang::class,
|
||||
'Log' => Illuminate\Support\Facades\Log::class,
|
||||
'Mail' => Illuminate\Support\Facades\Mail::class,
|
||||
'Notification' => Illuminate\Support\Facades\Notification::class,
|
||||
'Password' => Illuminate\Support\Facades\Password::class,
|
||||
'Queue' => Illuminate\Support\Facades\Queue::class,
|
||||
'Redirect' => Illuminate\Support\Facades\Redirect::class,
|
||||
'Redis' => Illuminate\Support\Facades\Redis::class,
|
||||
'Request' => Illuminate\Support\Facades\Request::class,
|
||||
'Response' => Illuminate\Support\Facades\Response::class,
|
||||
'Route' => Illuminate\Support\Facades\Route::class,
|
||||
'Schema' => Illuminate\Support\Facades\Schema::class,
|
||||
'Session' => Illuminate\Support\Facades\Session::class,
|
||||
'Storage' => Illuminate\Support\Facades\Storage::class,
|
||||
'Str' => Illuminate\Support\Str::class,
|
||||
'URL' => Illuminate\Support\Facades\URL::class,
|
||||
'Validator' => Illuminate\Support\Facades\Validator::class,
|
||||
'View' => Illuminate\Support\Facades\View::class,
|
||||
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
|
||||
|
||||
// Third Party
|
||||
'ImageTool' => Intervention\Image\Facades\Image::class,
|
||||
'DomPDF' => Barryvdh\DomPDF\Facade::class,
|
||||
'SnappyPDF' => Barryvdh\Snappy\Facades\SnappyPdf::class,
|
||||
|
||||
// Custom BookStack
|
||||
'Activity' => BookStack\Facades\Activity::class,
|
||||
'Theme' => BookStack\Facades\Theme::class,
|
||||
'Activity' => BookStack\Facades\Activity::class,
|
||||
'Setting' => BookStack\Facades\Setting::class,
|
||||
'Views' => BookStack\Facades\Views::class,
|
||||
'Images' => BookStack\Facades\Images::class,
|
||||
'Permissions' => BookStack\Facades\Permissions::class,
|
||||
|
||||
],
|
||||
|
||||
// Proxy configuration
|
||||
|
||||
@@ -10,18 +10,15 @@
|
||||
|
||||
return [
|
||||
|
||||
// Options: standard, ldap, saml2, oidc
|
||||
// Method of authentication to use
|
||||
// Options: standard, ldap, saml2
|
||||
'method' => env('AUTH_METHOD', 'standard'),
|
||||
|
||||
// Automatically initiate login via external auth system if it's the sole auth method.
|
||||
// Works with saml2 or oidc auth methods.
|
||||
'auto_initiate' => env('AUTH_AUTO_INITIATE', false),
|
||||
|
||||
// Authentication Defaults
|
||||
// This option controls the default authentication "guard" and password
|
||||
// reset options for your application.
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_METHOD', 'standard'),
|
||||
'guard' => env('AUTH_METHOD', 'standard'),
|
||||
'passwords' => 'users',
|
||||
],
|
||||
|
||||
@@ -29,26 +26,22 @@ return [
|
||||
// All authentication drivers have a user provider. This defines how the
|
||||
// users are actually retrieved out of your database or other storage
|
||||
// mechanisms used by this application to persist your user's data.
|
||||
// Supported drivers: "session", "api-token", "ldap-session", "async-external-session"
|
||||
// Supported drivers: "session", "api-token", "ldap-session"
|
||||
'guards' => [
|
||||
'standard' => [
|
||||
'driver' => 'session',
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
'ldap' => [
|
||||
'driver' => 'ldap-session',
|
||||
'driver' => 'ldap-session',
|
||||
'provider' => 'external',
|
||||
],
|
||||
'saml2' => [
|
||||
'driver' => 'async-external-session',
|
||||
'provider' => 'external',
|
||||
],
|
||||
'oidc' => [
|
||||
'driver' => 'async-external-session',
|
||||
'driver' => 'saml2-session',
|
||||
'provider' => 'external',
|
||||
],
|
||||
'api' => [
|
||||
'driver' => 'api-token',
|
||||
'driver' => 'api-token',
|
||||
],
|
||||
],
|
||||
|
||||
@@ -59,18 +52,12 @@ return [
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => \BookStack\Auth\User::class,
|
||||
'model' => \BookStack\Auth\User::class,
|
||||
],
|
||||
|
||||
'external' => [
|
||||
'driver' => 'external-users',
|
||||
'model' => \BookStack\Auth\User::class,
|
||||
'model' => \BookStack\Auth\User::class,
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
// Resetting Passwords
|
||||
@@ -80,17 +67,10 @@ return [
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'email' => 'emails.password',
|
||||
'table' => 'password_resets',
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
'email' => 'emails.password',
|
||||
'table' => 'password_resets',
|
||||
'expire' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
// Password Confirmation Timeout
|
||||
// Here you may define the amount of seconds before a password confirmation
|
||||
// times out and the user is prompted to re-enter their password via the
|
||||
// confirmation screen. By default, the timeout lasts for three hours.
|
||||
'password_timeout' => 10800,
|
||||
|
||||
];
|
||||
|
||||
@@ -23,18 +23,18 @@ return [
|
||||
'connections' => [
|
||||
|
||||
'pusher' => [
|
||||
'driver' => 'pusher',
|
||||
'key' => env('PUSHER_APP_KEY'),
|
||||
'secret' => env('PUSHER_APP_SECRET'),
|
||||
'app_id' => env('PUSHER_APP_ID'),
|
||||
'driver' => 'pusher',
|
||||
'key' => env('PUSHER_APP_KEY'),
|
||||
'secret' => env('PUSHER_APP_SECRET'),
|
||||
'app_id' => env('PUSHER_APP_ID'),
|
||||
'options' => [
|
||||
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||
'useTLS' => true,
|
||||
'useTLS' => true,
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
],
|
||||
|
||||
@@ -46,6 +46,7 @@ return [
|
||||
'driver' => 'null',
|
||||
],
|
||||
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Caching configuration options.
|
||||
*
|
||||
@@ -40,15 +38,13 @@ return [
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
'driver' => 'array',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'table' => 'cache',
|
||||
'connection' => null,
|
||||
'lock_connection' => null,
|
||||
'driver' => 'database',
|
||||
'table' => 'cache',
|
||||
'connection' => null,
|
||||
],
|
||||
|
||||
'file' => [
|
||||
@@ -57,36 +53,19 @@ return [
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => $memcachedServers ?? [],
|
||||
'driver' => 'memcached',
|
||||
'servers' => env('CACHE_DRIVER') === 'memcached' ? $memcachedServers : [],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
'lock_connection' => 'default',
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing a RAM based store such as APC or Memcached, there might
|
||||
| be other applications utilizing the same cache. So, we'll specify a
|
||||
| value to get prefixed to all our keys so we can avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache'),
|
||||
// Cache key prefix
|
||||
// Used to prevent collisions in shared cache systems.
|
||||
'prefix' => env('CACHE_PREFIX', 'bookstack_cache'),
|
||||
|
||||
];
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Enable Clockwork
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| Clockwork is enabled by default only when your application is in debug mode. Here you can explicitly enable or
|
||||
| disable Clockwork. When disabled, no data is collected and the api and web ui are inactive.
|
||||
|
|
||||
*/
|
||||
|
||||
'enable' => env('CLOCKWORK_ENABLE', false),
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Features
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| You can enable or disable various Clockwork features here. Some features have additional settings (eg. slow query
|
||||
| threshold for database queries).
|
||||
|
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
|
||||
// Cache usage stats and cache queries including results
|
||||
'cache' => [
|
||||
'enabled' => true,
|
||||
|
||||
// Collect cache queries
|
||||
'collect_queries' => true,
|
||||
|
||||
// Collect values from cache queries (high performance impact with a very high number of queries)
|
||||
'collect_values' => false,
|
||||
],
|
||||
|
||||
// Database usage stats and queries
|
||||
'database' => [
|
||||
'enabled' => true,
|
||||
|
||||
// Collect database queries (high performance impact with a very high number of queries)
|
||||
'collect_queries' => true,
|
||||
|
||||
// Collect details of models updates (high performance impact with a lot of model updates)
|
||||
'collect_models_actions' => true,
|
||||
|
||||
// Collect details of retrieved models (very high performance impact with a lot of models retrieved)
|
||||
'collect_models_retrieved' => false,
|
||||
|
||||
// Query execution time threshold in miliseconds after which the query will be marked as slow
|
||||
'slow_threshold' => null,
|
||||
|
||||
// Collect only slow database queries
|
||||
'slow_only' => false,
|
||||
|
||||
// Detect and report duplicate (N+1) queries
|
||||
'detect_duplicate_queries' => false,
|
||||
],
|
||||
|
||||
// Dispatched events
|
||||
'events' => [
|
||||
'enabled' => true,
|
||||
|
||||
// Ignored events (framework events are ignored by default)
|
||||
'ignored_events' => [
|
||||
// App\Events\UserRegistered::class,
|
||||
// 'user.registered'
|
||||
],
|
||||
],
|
||||
|
||||
// Laravel log (you can still log directly to Clockwork with laravel log disabled)
|
||||
'log' => [
|
||||
'enabled' => true,
|
||||
],
|
||||
|
||||
// Sent notifications
|
||||
'notifications' => [
|
||||
'enabled' => true,
|
||||
],
|
||||
|
||||
// Performance metrics
|
||||
'performance' => [
|
||||
// Allow collecting of client metrics. Requires separate clockwork-browser npm package.
|
||||
'client_metrics' => true,
|
||||
],
|
||||
|
||||
// Dispatched queue jobs
|
||||
'queue' => [
|
||||
'enabled' => true,
|
||||
],
|
||||
|
||||
// Redis commands
|
||||
'redis' => [
|
||||
'enabled' => true,
|
||||
],
|
||||
|
||||
// Routes list
|
||||
'routes' => [
|
||||
'enabled' => false,
|
||||
|
||||
// Collect only routes from particular namespaces (only application routes by default)
|
||||
'only_namespaces' => ['App'],
|
||||
],
|
||||
|
||||
// Rendered views
|
||||
'views' => [
|
||||
'enabled' => true,
|
||||
|
||||
// Collect views including view data (high performance impact with a high number of views)
|
||||
'collect_data' => false,
|
||||
|
||||
// Use Twig profiler instead of Laravel events for apps using laravel-twigbridge (more precise, but does
|
||||
// not support collecting view data)
|
||||
'use_twig_profiler' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Enable web UI
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| Clockwork comes with a web UI accessibla via http://your.app/clockwork. Here you can enable or disable this
|
||||
| feature. You can also set a custom path for the web UI.
|
||||
|
|
||||
*/
|
||||
|
||||
'web' => true,
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Enable toolbar
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| Clockwork can show a toolbar with basic metrics on all responses. Here you can enable or disable this feature.
|
||||
| Requires a separate clockwork-browser npm library.
|
||||
| For installation instructions see https://underground.works/clockwork/#docs-viewing-data
|
||||
|
|
||||
*/
|
||||
|
||||
'toolbar' => true,
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| HTTP requests collection
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| Clockwork collects data about HTTP requests to your app. Here you can choose which requests should be collected.
|
||||
|
|
||||
*/
|
||||
|
||||
'requests' => [
|
||||
// With on-demand mode enabled, Clockwork will only profile requests when the browser extension is open or you
|
||||
// manually pass a "clockwork-profile" cookie or get/post data key.
|
||||
// Optionally you can specify a "secret" that has to be passed as the value to enable profiling.
|
||||
'on_demand' => false,
|
||||
|
||||
// Collect only errors (requests with HTTP 4xx and 5xx responses)
|
||||
'errors_only' => false,
|
||||
|
||||
// Response time threshold in miliseconds after which the request will be marked as slow
|
||||
'slow_threshold' => null,
|
||||
|
||||
// Collect only slow requests
|
||||
'slow_only' => false,
|
||||
|
||||
// Sample the collected requests (eg. set to 100 to collect only 1 in 100 requests)
|
||||
'sample' => false,
|
||||
|
||||
// List of URIs that should not be collected
|
||||
'except' => [
|
||||
'/horizon/.*', // Laravel Horizon requests
|
||||
'/telescope/.*', // Laravel Telescope requests
|
||||
'/_debugbar/.*', // Laravel DebugBar requests
|
||||
],
|
||||
|
||||
// List of URIs that should be collected, any other URI will not be collected if not empty
|
||||
'only' => [
|
||||
// '/api/.*'
|
||||
],
|
||||
|
||||
// Don't collect OPTIONS requests, mostly used in the CSRF pre-flight requests and are rarely of interest
|
||||
'except_preflight' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Artisan commands collection
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| Clockwork can collect data about executed artisan commands. Here you can enable and configure which commands
|
||||
| should be collected.
|
||||
|
|
||||
*/
|
||||
|
||||
'artisan' => [
|
||||
// Enable or disable collection of executed Artisan commands
|
||||
'collect' => false,
|
||||
|
||||
// List of commands that should not be collected (built-in commands are not collected by default)
|
||||
'except' => [
|
||||
// 'inspire'
|
||||
],
|
||||
|
||||
// List of commands that should be collected, any other command will not be collected if not empty
|
||||
'only' => [
|
||||
// 'inspire'
|
||||
],
|
||||
|
||||
// Enable or disable collection of command output
|
||||
'collect_output' => false,
|
||||
|
||||
// Enable or disable collection of built-in Laravel commands
|
||||
'except_laravel_commands' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Queue jobs collection
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| Clockwork can collect data about executed queue jobs. Here you can enable and configure which queue jobs should
|
||||
| be collected.
|
||||
|
|
||||
*/
|
||||
|
||||
'queue' => [
|
||||
// Enable or disable collection of executed queue jobs
|
||||
'collect' => false,
|
||||
|
||||
// List of queue jobs that should not be collected
|
||||
'except' => [
|
||||
// App\Jobs\ExpensiveJob::class
|
||||
],
|
||||
|
||||
// List of queue jobs that should be collected, any other queue job will not be collected if not empty
|
||||
'only' => [
|
||||
// App\Jobs\BuggyJob::class
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Tests collection
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| Clockwork can collect data about executed tests. Here you can enable and configure which tests should be
|
||||
| collected.
|
||||
|
|
||||
*/
|
||||
|
||||
'tests' => [
|
||||
// Enable or disable collection of ran tests
|
||||
'collect' => false,
|
||||
|
||||
// List of tests that should not be collected
|
||||
'except' => [
|
||||
// Tests\Unit\ExampleTest::class
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Enable data collection when Clockwork is disabled
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| You can enable this setting to collect data even when Clockwork is disabled. Eg. for future analysis.
|
||||
|
|
||||
*/
|
||||
|
||||
'collect_data_always' => false,
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Metadata storage
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| Configure how is the metadata collected by Clockwork stored. Two options are available:
|
||||
| - files - A simple fast storage implementation storing data in one-per-request files.
|
||||
| - sql - Stores requests in a sql database. Supports MySQL, Postgresql, Sqlite and requires PDO.
|
||||
|
|
||||
*/
|
||||
|
||||
'storage' => 'files',
|
||||
|
||||
// Path where the Clockwork metadata is stored
|
||||
'storage_files_path' => storage_path('clockwork'),
|
||||
|
||||
// Compress the metadata files using gzip, trading a little bit of performance for lower disk usage
|
||||
'storage_files_compress' => false,
|
||||
|
||||
// SQL database to use, can be a name of database configured in database.php or a path to a sqlite file
|
||||
'storage_sql_database' => storage_path('clockwork.sqlite'),
|
||||
|
||||
// SQL table name to use, the table is automatically created and udpated when needed
|
||||
'storage_sql_table' => 'clockwork',
|
||||
|
||||
// Maximum lifetime of collected metadata in minutes, older requests will automatically be deleted, false to disable
|
||||
'storage_expiration' => 60 * 24 * 7,
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Authentication
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| Clockwork can be configured to require authentication before allowing access to the collected data. This might be
|
||||
| useful when the application is publicly accessible. Setting to true will enable a simple authentication with a
|
||||
| pre-configured password. You can also pass a class name of a custom implementation.
|
||||
|
|
||||
*/
|
||||
|
||||
'authentication' => false,
|
||||
|
||||
// Password for the simple authentication
|
||||
'authentication_password' => 'VerySecretPassword',
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Stack traces collection
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| Clockwork can collect stack traces for log messages and certain data like database queries. Here you can set
|
||||
| whether to collect stack traces, limit the number of collected frames and set further configuration. Collecting
|
||||
| long stack traces considerably increases metadata size.
|
||||
|
|
||||
*/
|
||||
|
||||
'stack_traces' => [
|
||||
// Enable or disable collecting of stack traces
|
||||
'enabled' => true,
|
||||
|
||||
// Limit the number of frames to be collected
|
||||
'limit' => 10,
|
||||
|
||||
// List of vendor names to skip when determining caller, common vendors are automatically added
|
||||
'skip_vendors' => [
|
||||
// 'phpunit'
|
||||
],
|
||||
|
||||
// List of namespaces to skip when determining caller
|
||||
'skip_namespaces' => [
|
||||
// 'Laravel'
|
||||
],
|
||||
|
||||
// List of class names to skip when determining caller
|
||||
'skip_classes' => [
|
||||
// App\CustomLog::class
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Serialization
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| Clockwork serializes the collected data to json for storage and transfer. Here you can configure certain aspects
|
||||
| of serialization. Serialization has a large effect on the cpu time and memory usage.
|
||||
|
|
||||
*/
|
||||
|
||||
// Maximum depth of serialized multi-level arrays and objects
|
||||
'serialization_depth' => 10,
|
||||
|
||||
// A list of classes that will never be serialized (eg. a common service container class)
|
||||
'serialization_blackbox' => [
|
||||
\Illuminate\Container\Container::class,
|
||||
\Illuminate\Foundation\Application::class,
|
||||
],
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Register helpers
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| Clockwork comes with a "clock" global helper function. You can use this helper to quickly log something and to
|
||||
| access the Clockwork instance.
|
||||
|
|
||||
*/
|
||||
|
||||
'register_helpers' => true,
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Send Headers for AJAX request
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| When trying to collect data the AJAX method can sometimes fail if it is missing required headers. For example, an
|
||||
| API might require a version number using Accept headers to route the HTTP request to the correct codebase.
|
||||
|
|
||||
*/
|
||||
|
||||
'headers' => [
|
||||
// 'Accept' => 'application/vnd.com.whatever.v1+json',
|
||||
],
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
| Server-Timing
|
||||
|------------------------------------------------------------------------------------------------------------------
|
||||
|
|
||||
| Clockwork supports the W3C Server Timing specification, which allows for collecting a simple performance metrics
|
||||
| in a cross-browser way. Eg. in Chrome, your app, database and timeline event timings will be shown in the Dev
|
||||
| Tools network tab. This setting specifies the max number of timeline events that will be sent. Setting to false
|
||||
| will disable the feature.
|
||||
|
|
||||
*/
|
||||
|
||||
'server_timing' => 10,
|
||||
|
||||
];
|
||||
@@ -59,41 +59,37 @@ return [
|
||||
'connections' => [
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DATABASE_URL'),
|
||||
'host' => $mysql_host,
|
||||
'database' => env('DB_DATABASE', 'forge'),
|
||||
'username' => env('DB_USERNAME', 'forge'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'port' => $mysql_port,
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
// Prefixes are only semi-supported and may be unstable
|
||||
// since they are not tested as part of our automated test suite.
|
||||
// If used, the prefix should not be changed otherwise you will likely receive errors.
|
||||
'prefix' => env('DB_TABLE_PREFIX', ''),
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DATABASE_URL'),
|
||||
'host' => $mysql_host,
|
||||
'database' => env('DB_DATABASE', 'forge'),
|
||||
'username' => env('DB_USERNAME', 'forge'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'port' => $mysql_port,
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => false,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
'strict' => false,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mysql_testing' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('TEST_DATABASE_URL'),
|
||||
'host' => '127.0.0.1',
|
||||
'database' => 'bookstack-test',
|
||||
'username' => env('MYSQL_USER', 'bookstack-test'),
|
||||
'password' => env('MYSQL_PASSWORD', 'bookstack-test'),
|
||||
'port' => $mysql_port,
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'prefix' => '',
|
||||
'driver' => 'mysql',
|
||||
'url' => env('TEST_DATABASE_URL'),
|
||||
'host' => '127.0.0.1',
|
||||
'database' => 'bookstack-test',
|
||||
'username' => env('MYSQL_USER', 'bookstack-test'),
|
||||
'password' => env('MYSQL_PASSWORD', 'bookstack-test'),
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => false,
|
||||
'strict' => false,
|
||||
],
|
||||
|
||||
],
|
||||
@@ -105,6 +101,6 @@ return [
|
||||
'migrations' => 'migrations',
|
||||
|
||||
// Redis configuration to use if set
|
||||
'redis' => $redisConfig ?? [],
|
||||
'redis' => env('REDIS_SERVERS', false) ? $redisConfig : [],
|
||||
|
||||
];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user