Compare commits

..

1 Commits

Author SHA1 Message Date
Dan Brown
c49454da28 Added crude example of captcha usage 2019-09-09 21:19:08 +01:00
1859 changed files with 37387 additions and 127605 deletions

View File

@@ -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.

View File

@@ -37,19 +37,6 @@ APP_AUTO_LANG_PUBLIC=true
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
APP_TIMEZONE=UTC
# Application theme
# Used to specific a themes/<APP_THEME> folder where BookStack UI
# 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 +45,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,8 +87,9 @@ 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_CONNECTION=sync
# Queue not really currently used but may be configurable in the future.
# Would advise not to change this for now.
QUEUE_DRIVER=sync
# Storage system to use
# Can be 'local', 'local_secure' or 's3'
@@ -140,13 +121,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' or 'ldap'
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,62 +190,16 @@ 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
# LDAP group sync configuration
# Refer to https://www.bookstackapp.com/docs/admin/ldap-auth/
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/
SAML2_NAME=SSO
SAML2_EMAIL_ATTRIBUTE=email
SAML2_DISPLAY_NAME_ATTRIBUTES=username
SAML2_EXTERNAL_ID_ATTRIBUTE=null
SAML2_IDP_ENTITYID=null
SAML2_IDP_SSO=null
SAML2_IDP_SLO=null
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/
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
OIDC_EXTERNAL_ID_CLAIM=sub
# Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option
@@ -280,44 +211,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.
@@ -329,44 +235,3 @@ 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

4
.github/FUNDING.yml vendored
View File

@@ -1,4 +0,0 @@
# These are supported funding model platforms
github: [ssddanbrown]
ko_fi: ssddanbrown

View File

@@ -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
View 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.

View File

@@ -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

View File

@@ -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.

View 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.

View File

@@ -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

View File

@@ -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.__*

View File

@@ -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
View File

@@ -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!

View File

@@ -1,310 +0,0 @@
Name :: Languages
@robertlandes :: German
@SergioMendolia :: French
@NakaharaL :: Portuguese, Brazilian
@ReeseSebastian :: German
@arietimmerman :: Dutch
@diegoseso :: Spanish
@S64 :: Japanese
@JachuPL :: Polish
@Joorem :: French
@timoschwarzer :: German
@sanderdw :: Dutch
@lbguilherme :: Portuguese, Brazilian
@marcusforsberg :: Swedish
@artur-trzesiok :: Polish
@Alwaysin :: French
@msaus :: Japanese
@moucho :: Spanish
@vriic :: German
@DeehSlash :: Portuguese, Brazilian
@alex2702 :: German
@nicobubulle :: French
@kmoj86 :: Arabic
@houbaron :: Chinese Traditional; Chinese Simplified
@mullinsmikey :: Russian
@limkukhyun :: Korean
@CliffyPrime :: German
@kejjang :: Chinese Traditional
@TheLastOperator :: French
@qianmengnet :: Simplified Chinese
@ezzra :: German; German Informal
@vasiliev123 :: Polish
@Mant1kor :: Ukrainian
@Xiphoseer :: German; German Informal
@maantje :: Dutch
@cima :: Czech
@agvol :: Russian
@Hambern :: Swedish
@NootoNooto :: Dutch
@kostefun :: Russian
@lucaguindani :: French
@miles75 :: Hungarian
@danielroehrig-mm :: German
@oykenfurkan :: Turkish
@qligier :: French
@johnroyer :: Traditional Chinese
@artskoczylas :: Polish
@dellamina :: Italian
@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
m0uch0 :: Spanish
Maxim Zalata (zlatin) :: Russian; Ukrainian
nutsflag :: French
Leonardo Mario Martinez (leonardo.m.martinez) :: Spanish, Argentina
Rodrigo Saczuk Niz (rodrigoniz) :: Portuguese, Brazilian
叫钦叔就好 (254351722) :: Chinese Traditional; Chinese Simplified
aekramer :: Dutch
JachuPL :: Polish
milesteg :: Hungarian
Beenbag :: German; German Informal
Lett3rs :: Danish
Julian (julian.henneberg) :: German; German Informal
3GNWn :: Danish
dbguichu :: Chinese Simplified
Randy Kim (hyunjun) :: Korean
Francesco M. Taurino (ftaurino) :: Italian
DanielFrederiksen :: Danish
Finn Wessel (19finnwessel6) :: German Informal; 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 :: ; French; 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 Informal; German
Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
구인회 (laskdjlaskdj12) :: Korean
LiZerui (CNLiZerui) :: Chinese Traditional
Fabrice Boyer (FabriceBoyer) :: French
mikael (bitcanon) :: Swedish
Matthias Mai (schnapsidee) :: German; German Informal
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
Jan Mitrof (jan.kachlik) :: Czech
edwardsmirnov :: Russian
Mr_OSS117 :: French
shotu :: French
Cesar_Lopez_Aguillon :: Spanish
bdewoop :: German
dina davoudi (dina.davoudi) :: Persian
Angelos Chouvardas (achouvardas) :: Greek
rndrss :: Portuguese, Brazilian
rirac294 :: Russian
David Furman (thefourCraft) :: Hebrew
Pafzedog :: French
Yllelder :: Spanish
Adrian Ocneanu (aocneanu) :: Romanian
Eduardo Castanho (EduardoCastanho) :: Portuguese
VIET NAM VPS (vietnamvps) :: Vietnamese
m4tthi4s :: French

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,53 +0,0 @@
name: test-php
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, gmp
- 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 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 "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: Migrate and seed the database
run: |
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
run: php${{ matrix.php }} ./vendor/bin/phpunit

11
.gitignore vendored
View File

@@ -5,10 +5,10 @@ Homestead.yaml
.idea
npm-debug.log
yarn-error.log
/public/dist/*.map
/public/dist
/public/plugins
/public/css/*.map
/public/js/*.map
/public/css
/public/js
/public/bower
/public/build/
/storage/images
@@ -21,7 +21,4 @@ nbproject
.buildpath
.project
.settings/
webpack-stats.json
.phpunit.result.cache
.DS_Store
phpstan.neon
webpack-stats.json

28
.travis.yml Normal file
View File

@@ -0,0 +1,28 @@
dist: trusty
sudo: false
language: php
php:
- 7.0.20
- 7.1.9
cache:
directories:
- $HOME/.composer/cache
before_script:
- mysql -u root -e 'create database `bookstack-test`;'
- mysql -u root -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED BY 'bookstack-test';"
- mysql -u root -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
- mysql -u root -e "FLUSH PRIVILEGES;"
- phpenv config-rm xdebug.ini
- composer install --prefer-dist --no-interaction
- php artisan clear-compiled -n
- php artisan optimize -n
- php artisan migrate --force -n --database=mysql_testing
- php artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
after_failure:
- cat storage/logs/laravel.log
script:
- phpunit

View File

@@ -1,6 +1,7 @@
The MIT License (MIT)
Copyright (c) 2015-2022, Dan Brown and the BookStack Project contributors.
Copyright (c) 2018 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

View File

@@ -2,75 +2,54 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Str;
/**
* @property string $type
* @property User $user
* @property Entity $entity
* @property string $detail
* @property string $entity_type
* @property int $entity_id
* @property int $user_id
* @property string key
* @property \User user
* @property \Entity entity
* @property string extra
*/
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);
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
}
/**
* 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];
}
}

View File

@@ -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);
}
}

View File

@@ -1,112 +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;
public function __construct(PermissionApplicator $permissions)
{
$this->permissions = $permissions;
}
/**
* Gets the latest activity.
*/
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['user', 'entity'])
->skip($count * $page)
->take($count)
->get();
return $this->filterSimilar($activityList);
}
/**
* Gets the latest activity for an entity, Filtering out similar
* items to prevent a message activity list.
*/
public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
{
/** @var 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
{
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), '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
*/
protected function filterSimilar(iterable $activities): array
{
$newActivity = [];
$previousItem = null;
foreach ($activities as $activityItem) {
if (!$previousItem || !$activityItem->isSimilarTo($previousItem)) {
$newActivity[] = $activityItem;
}
$previousItem = $activityItem;
}
return $newActivity;
}
}

View File

@@ -0,0 +1,177 @@
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Entity;
use Session;
class ActivityService
{
protected $activity;
protected $user;
protected $permissionService;
/**
* ActivityService constructor.
* @param \BookStack\Actions\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 Entity $entity
* @param $activityKey
* @param int $bookId
* @param bool $extra
*/
public function add(Entity $entity, $activityKey, $bookId = 0, $extra = false)
{
$activity = $this->activity->newInstance();
$activity->user_id = $this->user->id;
$activity->book_id = $bookId;
$activity->key = strtolower($activityKey);
if ($extra !== false) {
$activity->extra = $extra;
}
$entity->activity()->save($activity);
$this->setNotification($activityKey);
}
/**
* Adds a activity history with a message & without binding to a entity.
* @param $activityKey
* @param int $bookId
* @param bool|false $extra
*/
public function addMessage($activityKey, $bookId = 0, $extra = false)
{
$this->activity->user_id = $this->user->id;
$this->activity->book_id = $bookId;
$this->activity->key = strtolower($activityKey);
if ($extra !== false) {
$this->activity->extra = $extra;
}
$this->activity->save();
$this->setNotification($activityKey);
}
/**
* 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 Entity $entity
* @return mixed
*/
public function removeEntity(Entity $entity)
{
$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 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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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()

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Favourite extends Model
{
protected $fillable = ['user_id'];
/**
* Get the related model that can be favourited.
*/
public function favouritable(): MorphTo
{
return $this->morphTo();
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'favouritable_id')
->whereColumn('favourites.favouritable_type', '=', 'joint_permissions.entity_type');
}
}

View File

@@ -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'));
}
}

View File

@@ -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);
}
}

View File

@@ -1,53 +1,21 @@
<?php
<?php namespace BookStack\Actions;
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
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');
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('tags.entity_type', '=', 'joint_permissions.entity_type');
}
/**
* Get a full URL to start a tag name search for this tag name.
*/
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');
}
}

View File

@@ -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;
}
}

View File

@@ -1,96 +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();
}
$query = Tag::query()
->select([
'name',
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
DB::raw('COUNT(id) as usages'),
DB::raw('SUM(IF(entity_type = \'page\', 1, 0)) as page_count'),
DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'),
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
])
->orderBy($sort, $listOptions->getOrder());
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');
@@ -98,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);
}
}

View File

@@ -1,66 +1,18 @@
<?php
<?php namespace BookStack\Actions;
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
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();
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'viewable_id')
->whereColumn('views.viewable_type', '=', 'joint_permissions.entity_type');
}
/**
* Increment the current user's view count for the given viewable model.
*/
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();
}
}

112
app/Actions/ViewService.php Normal file
View File

@@ -0,0 +1,112 @@
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Entity;
use BookStack\Entities\EntityProvider;
use Illuminate\Support\Collection;
class ViewService
{
protected $view;
protected $permissionService;
protected $entityProvider;
/**
* ViewService constructor.
* @param \BookStack\Actions\View $view
* @param \BookStack\Auth\Permissions\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 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->create([
'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, $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();
}
}

View File

@@ -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}";
}
}

View File

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

View File

@@ -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'];
}

View File

@@ -1,186 +0,0 @@
<?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
{
$apiRoutes = $this->getFlatApiRoutes();
$apiRoutes = $this->loadDetailsFromControllers($apiRoutes);
$apiRoutes = $this->loadDetailsFromFiles($apiRoutes);
$apiRoutes = $apiRoutes->groupBy('base_model');
return $apiRoutes;
}
/**
* Load any API details stored in static files.
*/
protected function loadDetailsFromFiles(Collection $routes): Collection
{
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;
}
return $route;
});
}
/**
* Load any details we can fetch from the controller and its methods.
*/
protected function loadDetailsFromControllers(Collection $routes): Collection
{
return $routes->map(function (array $route) {
$method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
$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
*/
protected function getBodyParamsFromClass(string $className, string $methodName): ?array
{
/** @var ApiController $class */
$class = $this->controllerClasses[$className] ?? null;
if ($class === null) {
$class = app()->make($className);
$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;
}
if (is_object($validation) && method_exists($validation, '__toString')) {
return strval($validation);
}
if ($validation instanceof Password) {
return 'min:8';
}
$class = get_class($validation);
throw new Exception("Cannot provide string representation of rule for class: {$class}");
}
/**
* Parse out the description text from a class method comment.
*/
protected function parseDescriptionFromMethodComment(string $comment): string
{
$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
{
$class = $this->reflectionClasses[$className] ?? null;
if ($class === null) {
$class = new ReflectionClass($className);
$this->reflectionClasses[$className] = $class;
}
return $class->getMethod($methodName);
}
/**
* Get the system API routes, formatted into a flat collection.
*/
protected function getFlatApiRoutes(): Collection
{
return collect(Route::getRoutes()->getRoutes())->filter(function ($route) {
return strpos($route->uri, 'api/') === 0;
})->map(function ($route) {
[$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,
];
});
}
}

View File

@@ -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;
}
}

View File

@@ -1,52 +0,0 @@
<?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
{
protected $fillable = ['name', 'expires_at'];
protected $casts = [
'expires_at' => 'date:Y-m-d',
];
/**
* Get the user that this token belongs to.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the default expiry value for an API token.
* Set to 100 years from now.
*/
public static function defaultExpiry(): string
{
return Carbon::now()->addYears(100)->format('Y-m-d');
}
/**
* {@inheritdoc}
*/
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}; User: {$this->user->logDescriptor()}";
}
}

View File

@@ -1,181 +0,0 @@
<?php
namespace BookStack\Api;
use BookStack\Auth\Access\LoginService;
use BookStack\Exceptions\ApiAuthException;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\HttpFoundation\Request;
class ApiTokenGuard implements Guard
{
use GuardHelpers;
/**
* The request instance.
*/
protected $request;
/**
* @var LoginService
*/
protected $loginService;
/**
* The last auth exception thrown in this request.
*
* @var ApiAuthException
*/
protected $lastAuthException;
/**
* ApiTokenGuard constructor.
*/
public function __construct(Request $request, LoginService $loginService)
{
$this->request = $request;
$this->loginService = $loginService;
}
/**
* {@inheritdoc}
*/
public function user()
{
// Return the user if we've already retrieved them.
// Effectively a request-instance cache for this method.
if (!is_null($this->user)) {
return $this->user;
}
$user = null;
try {
$user = $this->getAuthorisedUserFromRequest();
} catch (ApiAuthException $exception) {
$this->lastAuthException = $exception;
}
$this->user = $user;
return $user;
}
/**
* Determine if current user is authenticated. If not, throw an exception.
*
* @throws ApiAuthException
*
* @return \Illuminate\Contracts\Auth\Authenticatable
*/
public function authenticate()
{
if (!is_null($user = $this->user())) {
return $user;
}
if ($this->lastAuthException) {
throw $this->lastAuthException;
}
throw new ApiAuthException('Unauthorized');
}
/**
* Check the API token in the request and fetch a valid authorised user.
*
* @throws ApiAuthException
*/
protected function getAuthorisedUserFromRequest(): Authenticatable
{
$authToken = trim($this->request->headers->get('Authorization', ''));
$this->validateTokenHeaderValue($authToken);
[$id, $secret] = explode(':', str_replace('Token ', '', $authToken));
$token = ApiToken::query()
->where('token_id', '=', $id)
->with(['user'])->first();
$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
{
if (empty($authToken)) {
throw new ApiAuthException(trans('errors.api_no_authorization_found'));
}
if (strpos($authToken, ':') === false || strpos($authToken, 'Token ') !== 0) {
throw new ApiAuthException(trans('errors.api_bad_authorization_format'));
}
}
/**
* 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
{
if ($token === null) {
throw new ApiAuthException(trans('errors.api_user_token_not_found'));
}
if (!Hash::check($secret, $token->secret)) {
throw new ApiAuthException(trans('errors.api_incorrect_token_secret'));
}
$now = Carbon::now();
if ($token->expires_at <= $now) {
throw new ApiAuthException(trans('errors.api_user_token_expired'), 403);
}
if (!$token->user->can('access-api')) {
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
}
}
/**
* {@inheritdoc}
*/
public function validate(array $credentials = [])
{
if (empty($credentials['id']) || empty($credentials['secret'])) {
return false;
}
$token = ApiToken::query()
->where('token_id', '=', $credentials['id'])
->with(['user'])->first();
if ($token === null) {
return false;
}
return Hash::check($credentials['secret'], $token->secret);
}
/**
* "Log out" the currently authenticated user.
*/
public function logout()
{
$this->user = null;
}
}

View File

@@ -1,170 +0,0 @@
<?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;
/**
* @var array<callable>
*/
protected array $resultModifiers = [];
/**
* @var array<string, string>
*/
protected array $filterOperators = [
'eq' => '=',
'ne' => '!=',
'gt' => '>',
'lt' => '<',
'gte' => '>=',
'lte' => '<=',
'like' => 'like',
];
/**
* ListingResponseBuilder constructor.
* The given fields will be forced visible within the model results.
*/
public function __construct(Builder $query, Request $request, array $fields)
{
$this->query = $query;
$this->request = $request;
$this->fields = $fields;
}
/**
* Get the response from this builder.
*/
public function toResponse(): JsonResponse
{
$filteredQuery = $this->filterQuery($this->query);
$total = $filteredQuery->count();
$data = $this->fetchData($filteredQuery)->each(function ($model) {
foreach ($this->resultModifiers as $modifier) {
$modifier($model);
}
});
return response()->json([
'data' => $data,
'total' => $total,
]);
}
/**
* Add a callback to modify each element of the results.
*
* @param (callable(Model): void) $modifier
*/
public function modifyResults(callable $modifier): void
{
$this->resultModifiers[] = $modifier;
}
/**
* 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);
}
/**
* Apply any filtering operations found in the request.
*/
protected function filterQuery(Builder $query): Builder
{
$query = clone $query;
$requestFilters = $this->request->get('filter', []);
if (!is_array($requestFilters)) {
return $query;
}
$queryFilters = collect($requestFilters)->map(function ($value, $key) {
return $this->requestFilterToQueryFilter($key, $value);
})->filter(function ($value) {
return !is_null($value);
})->values()->toArray();
return $query->where($queryFilters);
}
/**
* Convert a request filter query key/value pair into a [field, op, value] where condition.
*/
protected function requestFilterToQueryFilter($fieldKey, $value): ?array
{
$splitKey = explode(':', $fieldKey);
$field = $splitKey[0];
$filterOperator = $splitKey[1] ?? 'eq';
if (!in_array($field, $this->fields)) {
return null;
}
if (!in_array($filterOperator, array_keys($this->filterOperators))) {
$filterOperator = 'eq';
}
$queryOperator = $this->filterOperators[$filterOperator];
return [$field, $queryOperator, $value];
}
/**
* Apply sorting operations to the query from given parameters
* otherwise falling back to the first given field, ascending.
*/
protected function sortQuery(Builder $query): Builder
{
$query = clone $query;
$defaultSortName = $this->fields[0];
$direction = 'asc';
$sort = $this->request->get('sort', '');
if (strpos($sort, '-') === 0) {
$direction = 'desc';
}
$sortName = ltrim($sort, '+- ');
if (!in_array($sortName, $this->fields)) {
$sortName = $defaultSortName;
}
return $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
{
$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);
}
}

View File

@@ -4,20 +4,16 @@ 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 = '')
{
return $this->basePath
. DIRECTORY_SEPARATOR
. 'app'
. DIRECTORY_SEPARATOR
. 'Config'
. ($path ? DIRECTORY_SEPARATOR . $path : $path);
return $this->basePath.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Config'.($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}
}

View File

@@ -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,10 +29,12 @@ 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');
}
}

View File

@@ -1,106 +0,0 @@
<?php
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.
*
* @var string
*/
protected $model;
/**
* LdapUserProvider constructor.
*/
public function __construct(string $model)
{
$this->model = $model;
}
/**
* Create a new instance of the model.
*
* @return Model
*/
public function createModel()
{
$class = '\\' . ltrim($this->model, '\\');
return new $class();
}
/**
* Retrieve a user by their unique identifier.
*
* @param mixed $identifier
*
* @return Authenticatable|null
*/
public function retrieveById($identifier)
{
return $this->createModel()->newQuery()->find($identifier);
}
/**
* Retrieve a user by their unique identifier and "remember me" token.
*
* @param mixed $identifier
* @param string $token
*
* @return 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
*
* @return void
*/
public function updateRememberToken(Authenticatable $user, $token)
{
//
}
/**
* Retrieve a user by the given credentials.
*
* @param array $credentials
*
* @return 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();
}
/**
* Validate a user against the given credentials.
*
* @param Authenticatable $user
* @param array $credentials
*
* @return bool
*/
public function validateCredentials(Authenticatable $user, array $credentials)
{
// Should be done in the guard.
return false;
}
}

View File

@@ -1,86 +0,0 @@
<?php
namespace BookStack\Auth\Access;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use Illuminate\Support\Collection;
class GroupSyncService
{
/**
* Check a role against an array of group names to see if it matches.
* Checked against role 'external_auth_id' if set otherwise the name of the role.
*/
protected function roleMatchesGroupNames(Role $role, array $groupNames): bool
{
if ($role->external_auth_id) {
return $this->externalIdMatchesGroupNames($role->external_auth_id, $groupNames);
}
$roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
return in_array($roleName, $groupNames);
}
/**
* Check if the given external auth ID string matches one of the given group names.
*/
protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool
{
foreach ($this->parseRoleExternalAuthId($externalId) as $externalAuthId) {
if (in_array($externalAuthId, $groupNames)) {
return true;
}
}
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.
*/
protected function matchGroupsToSystemsRoles(array $groupNames): Collection
{
foreach ($groupNames as $i => $groupName) {
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
}
$roles = Role::query()->get(['id', 'external_auth_id', 'display_name']);
$matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
return $this->roleMatchesGroupNames($role, $groupNames);
});
return $matchedRoles->pluck('id');
}
/**
* Sync the groups to the user roles for the current user.
*/
public function syncUserWithFoundGroups(User $user, array $userGroups, bool $detachExisting): void
{
// Get the ids for the roles from the names
$groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups);
// Sync groups
if ($detachExisting) {
$user->roles()->sync($groupsAsRoles);
$user->attachDefaultRole();
} else {
$user->roles()->syncWithoutDetaching($groupsAsRoles);
}
}
}

View File

@@ -1,39 +0,0 @@
<?php
namespace BookStack\Auth\Access\Guards;
/**
* 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.
*/
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
{
/**
* Validate a user's credentials.
*
* @param array $credentials
*
* @return bool
*/
public function validate(array $credentials = [])
{
return false;
}
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
*
* @return bool
*/
public function attempt(array $credentials = [], $remember = false)
{
return false;
}
}

View File

@@ -1,305 +0,0 @@
<?php
namespace BookStack\Auth\Access\Guards;
use BookStack\Auth\Access\RegistrationService;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Session\Session;
/**
* Class BaseSessionGuard
* A base implementation of a session guard. Is a copy of the default Laravel
* 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.
*/
class ExternalBaseSessionGuard implements StatefulGuard
{
use GuardHelpers;
/**
* The name of the Guard. Typically "session".
*
* Corresponds to guard name in authentication configuration.
*
* @var string
*/
protected $name;
/**
* The user we last attempted to retrieve.
*
* @var \Illuminate\Contracts\Auth\Authenticatable
*/
protected $lastAttempted;
/**
* The session used by the guard.
*
* @var \Illuminate\Contracts\Session\Session
*/
protected $session;
/**
* Indicates if the logout method has been called.
*
* @var bool
*/
protected $loggedOut = false;
/**
* Service to handle common registration actions.
*
* @var RegistrationService
*/
protected $registrationService;
/**
* Create a new authentication guard.
*
* @return void
*/
public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService)
{
$this->name = $name;
$this->session = $session;
$this->provider = $provider;
$this->registrationService = $registrationService;
}
/**
* Get the currently authenticated user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function user()
{
if ($this->loggedOut) {
return;
}
// 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)) {
return $this->user;
}
$id = $this->session->get($this->getName());
// First we will try to load the user using the
// identifier in the session if one exists.
if (!is_null($id)) {
$this->user = $this->provider->retrieveById($id);
}
return $this->user;
}
/**
* Get the ID for the currently authenticated user.
*
* @return int|null
*/
public function id()
{
if ($this->loggedOut) {
return;
}
return $this->user()
? $this->user()->getAuthIdentifier()
: $this->session->get($this->getName());
}
/**
* Log a user into the application without sessions or cookies.
*
* @param array $credentials
*
* @return bool
*/
public function once(array $credentials = [])
{
if ($this->validate($credentials)) {
$this->setUser($this->lastAttempted);
return true;
}
return false;
}
/**
* Log the given user ID into the application without sessions or cookies.
*
* @param mixed $id
*
* @return \Illuminate\Contracts\Auth\Authenticatable|false
*/
public function onceUsingId($id)
{
if (!is_null($user = $this->provider->retrieveById($id))) {
$this->setUser($user);
return $user;
}
return false;
}
/**
* Validate a user's credentials.
*
* @param array $credentials
*
* @return bool
*/
public function validate(array $credentials = [])
{
return false;
}
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
*
* @return bool
*/
public function attempt(array $credentials = [], $remember = false)
{
return false;
}
/**
* Log the given user ID into the application.
*
* @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.
return false;
}
/**
* Log a user into the application.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param bool $remember
*
* @return void
*/
public function login(AuthenticatableContract $user, $remember = false)
{
$this->updateSession($user->getAuthIdentifier());
$this->setUser($user);
}
/**
* Update the session with the given ID.
*
* @param string $id
*
* @return void
*/
protected function updateSession($id)
{
$this->session->put($this->getName(), $id);
$this->session->migrate(true);
}
/**
* Log the user out of the application.
*
* @return void
*/
public function logout()
{
$this->clearUserDataFromStorage();
// Now we will clear the users out of memory so they are no longer available
// as the user is no longer considered as being signed into this
// application and should not be available here.
$this->user = null;
$this->loggedOut = true;
}
/**
* Remove the user data from the session and cookies.
*
* @return void
*/
protected function clearUserDataFromStorage()
{
$this->session->remove($this->getName());
}
/**
* Get the last user we attempted to authenticate.
*
* @return \Illuminate\Contracts\Auth\Authenticatable
*/
public function getLastAttempted()
{
return $this->lastAttempted;
}
/**
* Get a unique identifier for the auth session value.
*
* @return string
*/
public function getName()
{
return 'login_' . $this->name . '_' . sha1(static::class);
}
/**
* Determine if the user was authenticated via "remember me" cookie.
*
* @return bool
*/
public function viaRemember()
{
return false;
}
/**
* Return the currently cached user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function getUser()
{
return $this->user;
}
/**
* Set the current user.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
*
* @return $this
*/
public function setUser(AuthenticatableContract $user)
{
$this->user = $user;
$this->loggedOut = false;
return $this;
}
}

View File

@@ -1,135 +0,0 @@
<?php
namespace BookStack\Auth\Access\Guards;
use BookStack\Auth\Access\LdapService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\LdapException;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Exceptions\UserRegistrationException;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Str;
class LdapSessionGuard extends ExternalBaseSessionGuard
{
protected LdapService $ldapService;
/**
* LdapSessionGuard constructor.
*/
public function __construct(
$name,
UserProvider $provider,
Session $session,
LdapService $ldapService,
RegistrationService $registrationService
) {
$this->ldapService = $ldapService;
parent::__construct($name, $provider, $session, $registrationService);
}
/**
* Validate a user's credentials.
*
* @param array $credentials
*
* @throws LdapException
*
* @return bool
*/
public function validate(array $credentials = [])
{
$userDetails = $this->ldapService->getUserDetails($credentials['username']);
if (isset($userDetails['uid'])) {
$this->lastAttempted = $this->provider->retrieveByCredentials([
'external_auth_id' => $userDetails['uid'],
]);
}
return $this->ldapService->validateUserCredentials($userDetails, $credentials['password']);
}
/**
* 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
*
* @return bool
*/
public function attempt(array $credentials = [], $remember = false)
{
$username = $credentials['username'];
$userDetails = $this->ldapService->getUserDetails($username);
$user = null;
if (isset($userDetails['uid'])) {
$this->lastAttempted = $user = $this->provider->retrieveByCredentials([
'external_auth_id' => $userDetails['uid'],
]);
}
if (!$this->ldapService->validateUserCredentials($userDetails, $credentials['password'])) {
return false;
}
if (is_null($user)) {
try {
$user = $this->createNewFromLdapAndCreds($userDetails, $credentials);
} catch (UserRegistrationException $exception) {
throw new LoginAttemptException($exception->getMessage());
}
}
// Sync LDAP groups if required
if ($this->ldapService->shouldSyncGroups()) {
$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.
*
* @throws LoginAttemptEmailNeededException
* @throws LoginAttemptException
* @throws UserRegistrationException
*/
protected function createNewFromLdapAndCreds(array $ldapUserDetails, array $credentials): User
{
$email = trim($ldapUserDetails['email'] ?: ($credentials['email'] ?? ''));
if (empty($email)) {
throw new LoginAttemptEmailNeededException();
}
$details = [
'name' => $ldapUserDetails['name'],
'email' => $ldapUserDetails['email'] ?: $credentials['email'],
'external_auth_id' => $ldapUserDetails['uid'],
'password' => Str::random(32),
];
$user = $this->registrationService->registerUser($details, null, false);
$this->ldapService->saveAndAttachAvatar($user, $ldapUserDetails);
return $user;
}
}

View File

@@ -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);
}

View File

@@ -1,69 +1,61 @@
<?php
namespace BookStack\Auth\Access;
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\Access;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\LdapException;
use BookStack\Uploads\UserAvatars;
use ErrorException;
use Illuminate\Support\Facades\Log;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
/**
* Class LdapService
* Handles any app-specific LDAP tasks.
* @package BookStack\Services
*/
class LdapService
{
protected Ldap $ldap;
protected GroupSyncService $groupSyncService;
protected UserAvatars $userAvatars;
/**
* @var resource
*/
protected $ldap;
protected $ldapConnection;
protected array $config;
protected bool $enabled;
protected $config;
protected $userRepo;
protected $enabled;
/**
* LdapService constructor.
* @param Ldap $ldap
* @param \BookStack\Auth\UserRepo $userRepo
*/
public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
public function __construct(Access\Ldap $ldap, UserRepo $userRepo)
{
$this->ldap = $ldap;
$this->userAvatars = $userAvatars;
$this->groupSyncService = $groupSyncService;
$this->config = config('services.ldap');
$this->userRepo = $userRepo;
$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.
*
* Search for attributes for a specific user on the ldap
* @param string $userName
* @param array $attributes
* @return null|array
* @throws LdapException
*/
private function getUserWithAttributes(string $userName, array $attributes): ?array
private function getUserWithAttributes($userName, $attributes)
{
$ldapConnection = $this->getConnection();
$this->bindSystemUser($ldapConnection);
// Clean attributes
foreach ($attributes as $index => $attribute) {
if (strpos($attribute, 'BIN;') === 0) {
$attributes[$index] = substr($attribute, strlen('BIN;'));
}
}
// Find user
$userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]);
$baseDn = $this->config['base_dn'];
@@ -81,85 +73,69 @@ class LdapService
/**
* Get the details of a user from LDAP using the given username.
* User found via configurable user filter.
*
* @param $userName
* @return array|null
* @throws LdapException
*/
public function getUserDetails(string $userName): ?array
public function getUserDetails($userName)
{
$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', 'uid', 'dn', $emailAttr, $displayNameAttr]);
if (is_null($user)) {
if ($user === null) {
return null;
}
$userCn = $this->getUserResponseProperty($user, 'cn', null);
$formatted = [
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
return [
'uid' => $this->getUserResponseProperty($user, 'uid', $user['dn']),
'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_bookstack_parsed' => $formatted,
]);
}
return $formatted;
}
/**
* Get a property from an LDAP user response fetch.
* Handles properties potentially being part of an array.
* If the given key is prefixed with 'BIN;', that indicator will be stripped
* from the key and any fetched values will be converted from binary to hex.
* @param array $userDetails
* @param string $propertyKey
* @param $defaultValue
* @return mixed
*/
protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
{
$isBinary = strpos($propertyKey, 'BIN;') === 0;
$propertyKey = strtolower($propertyKey);
$value = $defaultValue;
if ($isBinary) {
$propertyKey = substr($propertyKey, strlen('BIN;'));
}
if (isset($userDetails[$propertyKey])) {
$value = (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
if ($isBinary) {
$value = bin2hex($value);
}
return (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
}
return $value;
return $defaultValue;
}
/**
* Check if the given credentials are valid for the given user.
*
* @param Authenticatable $user
* @param string $username
* @param string $password
* @return bool
* @throws LdapException
*/
public function validateUserCredentials(?array $ldapUserDetails, string $password): bool
public function validateUserCredentials(Authenticatable $user, $username, $password)
{
if (is_null($ldapUserDetails)) {
$ldapUser = $this->getUserDetails($username);
if ($ldapUser === null) {
return false;
}
if ($ldapUser['uid'] !== $user->external_auth_id) {
return false;
}
$ldapConnection = $this->getConnection();
try {
$ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password);
} catch (ErrorException $e) {
$ldapBind = $this->ldap->bind($ldapConnection, $ldapUser['dn'], $password);
} catch (\ErrorException $e) {
$ldapBind = false;
}
@@ -169,9 +145,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 +168,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 +182,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,24 +200,17 @@ 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;
}
/**
* Parse a LDAP server string and return the host and port for a connection.
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
* Parse a LDAP server string and return the host and port for
* a connection. Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'
* @param $serverString
* @return array
*/
protected function parseServerString(string $serverString): array
protected function parseServerString($serverString)
{
$serverNameParts = explode(':', $serverString);
@@ -257,31 +222,32 @@ class LdapService
// Otherwise, extract the port out
$hostName = $serverNameParts[0];
$ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
return ['host' => $hostName, 'port' => $ldapPort];
}
/**
* Build a filter string by injecting common variables.
* @param string $filterString
* @param array $attrs
* @return string
*/
protected function buildFilter(string $filterString, array $attrs): string
protected function buildFilter($filterString, array $attrs)
{
$newAttrs = [];
foreach ($attrs as $key => $attrText) {
$newKey = '${' . $key . '}';
$newAttrs[$newKey] = $this->ldap->escape($attrText);
}
return strtr($filterString, $newAttrs);
}
/**
* Get the groups a user is a part of on ldap.
*
* Get the groups a user is a part of on ldap
* @param string $userName
* @return array
* @throws LdapException
* @throws JsonDebugException
*/
public function getUserGroups(string $userName): array
public function getUserGroups($userName)
{
$groupsAttr = $this->config['group_attribute'];
$user = $this->getUserWithAttributes($userName, [$groupsAttr]);
@@ -291,52 +257,45 @@ 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.
*
* Get the parent groups of an array of groups
* @param array $groupsArray
* @param array $checked
* @return array
* @throws LdapException
*/
private function getGroupsRecursive(array $groupsArray, array $checked): array
private function getGroupsRecursive($groupsArray, $checked)
{
$groupsToAdd = [];
$groups_to_add = [];
foreach ($groupsArray as $groupName) {
if (in_array($groupName, $checked)) {
continue;
}
$parentGroups = $this->getGroupGroups($groupName);
$groupsToAdd = array_merge($groupsToAdd, $parentGroups);
$groupsToAdd = $this->getGroupGroups($groupName);
$groups_to_add = array_merge($groups_to_add, $groupsToAdd);
$checked[] = $groupName;
}
$groupsArray = array_unique(array_merge($groupsArray, $groups_to_add), SORT_REGULAR);
$groupsArray = array_unique(array_merge($groupsArray, $groupsToAdd), SORT_REGULAR);
if (empty($groupsToAdd)) {
if (!empty($groups_to_add)) {
return $this->getGroupsRecursive($groupsArray, $checked);
} else {
return $groupsArray;
}
return $this->getGroupsRecursive($groupsArray, $checked);
}
/**
* Get the parent groups of a single group.
*
* Get the parent groups of a single group
* @param string $groupName
* @return array
* @throws LdapException
*/
private function getGroupGroups(string $groupName): array
private function getGroupGroups($groupName)
{
$ldapConnection = $this->getConnection();
$this->bindSystemUser($ldapConnection);
@@ -353,21 +312,24 @@ class LdapService
return [];
}
return $this->groupFilter($groups[0]);
$groupGroups = $this->groupFilter($groups[0]);
return $groupGroups;
}
/**
* Filter out LDAP CN and DN language in a ldap search return.
* Gets the base CN (common name) of the string.
* Filter out LDAP CN and DN language in a ldap search return
* Gets the base CN (common name) of the string
* @param array $userGroupSearchResponse
* @return array
*/
protected function groupFilter(array $userGroupSearchResponse): array
protected function groupFilter(array $userGroupSearchResponse)
{
$groupsAttr = strtolower($this->config['group_attribute']);
$ldapGroups = [];
$count = 0;
if (isset($userGroupSearchResponse[$groupsAttr]['count'])) {
$count = (int) $userGroupSearchResponse[$groupsAttr]['count'];
$count = (int)$userGroupSearchResponse[$groupsAttr]['count'];
}
for ($i = 0; $i < $count; $i++) {
@@ -381,32 +343,73 @@ class LdapService
}
/**
* Sync the LDAP groups to the user roles for the current user.
*
* Sync the LDAP groups to the user roles for the current user
* @param \BookStack\Auth\User $user
* @param string $username
* @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']);
// Get the ids for the roles from the names
$ldapGroupsAsRoles = $this->matchLdapGroupsToSystemsRoles($userLdapGroups);
// Sync groups
if ($this->config['remove_from_groups']) {
$user->roles()->sync($ldapGroupsAsRoles);
$this->userRepo->attachDefaultRole($user);
} else {
$user->roles()->syncWithoutDetaching($ldapGroupsAsRoles);
}
}
/**
* Save and attach an avatar image, if found in the ldap details, and attach
* to the given user model.
* Match an array of group names from LDAP to BookStack system roles.
* Formats LDAP group names to be lower-case and hyphenated.
* @param array $groupNames
* @return \Illuminate\Support\Collection
*/
public function saveAndAttachAvatar(User $user, array $ldapUserDetails): void
protected function matchLdapGroupsToSystemsRoles(array $groupNames)
{
if (is_null(config('services.ldap.thumbnail_attribute')) || is_null($ldapUserDetails['avatar'])) {
return;
foreach ($groupNames as $i => $groupName) {
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
}
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}");
$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);
});
return $matchedRoles->pluck('id');
}
/**
* Check a role against an array of group names to see if it matches.
* Checked against role 'external_auth_id' if set otherwise the name of the role.
* @param \BookStack\Auth\Role $role
* @param array $groupNames
* @return bool
*/
protected function roleMatchesGroupNames(Role $role, array $groupNames)
{
if ($role->external_auth_id) {
$externalAuthIds = explode(',', strtolower($role->external_auth_id));
foreach ($externalAuthIds as $externalAuthId) {
if (in_array(trim($externalAuthId), $groupNames)) {
return true;
}
}
return false;
}
$roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
return in_array($roleName, $groupNames);
}
}

View File

@@ -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;
}
}

View File

@@ -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)));
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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');
}
}

View File

@@ -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'];
}
}

View File

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

View File

@@ -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');
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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');
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -1,286 +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
{
$idClaim = $this->config()['external_id_claim'];
$id = $token->getClaim($idClaim);
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;
}
}

View File

@@ -1,149 +0,0 @@
<?php
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;
/**
* RegistrationService constructor.
*/
public function __construct(UserRepo $userRepo, EmailConfirmationService $emailConfirmationService)
{
$this->userRepo = $userRepo;
$this->emailConfirmationService = $emailConfirmationService;
}
/**
* Check whether or not registrations are allowed in the app settings.
*
* @throws UserRegistrationException
*/
public function ensureRegistrationAllowed()
{
if (!$this->registrationAllowed()) {
throw new UserRegistrationException(trans('auth.registrations_disabled'), '/login');
}
}
/**
* Check if standard BookStack User registrations are currently allowed.
* Does not prevent external-auth based registration.
*/
protected function registrationAllowed(): bool
{
$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
{
$userEmail = $userData['email'];
// Email restriction
$this->ensureEmailDomainAllowed($userEmail);
// 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');
}
// Create the user
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
$newUser->attachDefaultRole();
// 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();
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');
}
}
return $newUser;
}
/**
* 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
{
$registrationRestrict = setting('registration-restrict');
if (!$registrationRestrict) {
return;
}
$restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
$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);
}
}
}

View File

@@ -1,394 +0,0 @@
<?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 OneLogin\Saml2\Auth;
use OneLogin\Saml2\Constants;
use OneLogin\Saml2\Error;
use OneLogin\Saml2\IdPMetadataParser;
use OneLogin\Saml2\ValidationError;
/**
* Class Saml2Service
* Handles any app-specific SAML tasks.
*/
class Saml2Service
{
protected array $config;
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected GroupSyncService $groupSyncService;
public function __construct(
RegistrationService $registrationService,
LoginService $loginService,
GroupSyncService $groupSyncService
) {
$this->config = config('saml2');
$this->registrationService = $registrationService;
$this->loginService = $loginService;
$this->groupSyncService = $groupSyncService;
}
/**
* 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(),
];
}
/**
* Initiate a logout flow.
*
* @throws Error
*/
public function logout(User $user): array
{
$toolKit = $this->getToolkit();
$returnRoute = url('/');
try {
$url = $toolKit->logout(
$returnRoute,
[],
$user->email,
null,
true,
Constants::NAMEID_EMAIL_ADDRESS
);
$id = $toolKit->getLastRequestID();
} catch (Error $error) {
if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) {
throw $error;
}
$this->actionLogout();
$url = '/';
$id = null;
}
return ['url' => $url, 'id' => $id];
}
/**
* 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
{
// 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);
}
if (!$toolkit->isAuthenticated()) {
return null;
}
$attrs = $toolkit->getAttributes();
$id = $toolkit->getNameId();
return $this->processLoginCallback($id, $attrs);
}
/**
* Process a response for the single logout service.
*
* @throws Error
*/
public function processSlsResponse(?string $requestId): ?string
{
$toolkit = $this->getToolkit();
// 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)
);
}
$this->actionLogout();
return $redirect;
}
/**
* Do the required actions to log a user out.
*/
protected function actionLogout()
{
auth()->logout();
session()->invalidate();
}
/**
* Get the metadata for this service provider.
*
* @throws Error
*/
public function metadata(): string
{
$toolKit = $this->getToolkit(true);
$settings = $toolKit->getSettings();
$metadata = $settings->getSPMetadata();
$errors = $settings->validateMetadata($metadata);
if (!empty($errors)) {
throw new Error(
'Invalid SP metadata: ' . implode(', ', $errors),
Error::METADATA_SP_INVALID
);
}
return $metadata;
}
/**
* Load the underlying Onelogin SAML2 toolkit.
*
* @throws Error
* @throws Exception
*/
protected function getToolkit(bool $spOnly = false): Auth
{
$settings = $this->config['onelogin'];
$overrides = $this->config['onelogin_overrides'] ?? [];
if ($overrides && is_string($overrides)) {
$overrides = json_decode($overrides, true);
}
$metaDataSettings = [];
if (!$spOnly && $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);
}
/**
* Load dynamic service provider options required by the onelogin toolkit.
*/
protected function loadOneloginServiceProviderDetails(): array
{
$spDetails = [
'entityId' => url('/saml2/metadata'),
'assertionConsumerService' => [
'url' => url('/saml2/acs'),
],
'singleLogoutService' => [
'url' => url('/saml2/sls'),
],
];
return [
'baseurl' => url('/saml2'),
'sp' => $spDetails,
];
}
/**
* Check if groups should be synced.
*/
protected function shouldSyncGroups(): bool
{
return $this->config['user_to_groups'] !== false;
}
/**
* Calculate the display name.
*/
protected function getUserDisplayName(array $samlAttributes, string $defaultValue): string
{
$displayNameAttr = $this->config['display_name_attributes'];
$displayName = [];
foreach ($displayNameAttr as $dnAttr) {
$dnComponent = $this->getSamlResponseAttribute($samlAttributes, $dnAttr, null);
if ($dnComponent !== null) {
$displayName[] = $dnComponent;
}
}
if (count($displayName) == 0) {
$displayName = $defaultValue;
} else {
$displayName = implode(' ', $displayName);
}
return $displayName;
}
/**
* Get the value to use as the external id saved in BookStack
* used to link the user to an existing BookStack DB user.
*/
protected function getExternalId(array $samlAttributes, string $defaultValue)
{
$userNameAttr = $this->config['external_id_attribute'];
if ($userNameAttr === null) {
return $defaultValue;
}
return $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $defaultValue);
}
/**
* 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
{
$emailAttr = $this->config['email_attribute'];
$externalId = $this->getExternalId($samlAttributes, $samlID);
$defaultEmail = filter_var($samlID, FILTER_VALIDATE_EMAIL) ? $samlID : null;
$email = $this->getSamlResponseAttribute($samlAttributes, $emailAttr, $defaultEmail);
return [
'external_id' => $externalId,
'name' => $this->getUserDisplayName($samlAttributes, $externalId),
'email' => $email,
'saml_id' => $samlID,
];
}
/**
* Get the groups a user is a part of from the SAML response.
*/
public function getUserGroups(array $samlAttributes): array
{
$groupsAttr = $this->config['group_attribute'];
$userGroups = $samlAttributes[$groupsAttr] ?? null;
if (!is_array($userGroups)) {
$userGroups = [];
}
return $userGroups;
}
/**
* For an array of strings, return a default for an empty array,
* a string for an array with one element and the full array for
* more than one element.
*/
protected function simplifyValue(array $data, $defaultValue)
{
switch (count($data)) {
case 0:
$data = $defaultValue;
break;
case 1:
$data = $data[0];
break;
}
return $data;
}
/**
* Get a property from an SAML response.
* Handles properties potentially being an array.
*/
protected function getSamlResponseAttribute(array $samlAttributes, string $propertyKey, $defaultValue)
{
if (isset($samlAttributes[$propertyKey])) {
return $this->simplifyValue($samlAttributes[$propertyKey], $defaultValue);
}
return $defaultValue;
}
/**
* 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
{
$userDetails = $this->getUserDetails($samlID, $samlAttributes);
$isLoggedIn = auth()->check();
if ($this->config['dump_user_details']) {
throw new JsonDebugException([
'id_from_idp' => $samlID,
'attrs_from_idp' => $samlAttributes,
'attrs_after_parsing' => $userDetails,
]);
}
if ($userDetails['email'] === null) {
throw new SamlException(trans('errors.saml_no_email_address'));
}
if ($isLoggedIn) {
throw new SamlException(trans('errors.saml_already_logged_in'), '/login');
}
$user = $this->registrationService->findOrRegister(
$userDetails['name'],
$userDetails['email'],
$userDetails['external_id']
);
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->loginService->login($user, 'saml2');
return $user;
}
}

View File

@@ -1,112 +1,77 @@
<?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.
* @param \BookStack\Auth\UserRepo $userRepo
* @param Socialite $socialite
* @param SocialAccount $socialAccount
*/
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.
*
* @param string $socialDriver
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* @throws SocialDriverNotConfigured
*/
public function startLogIn(string $socialDriver): RedirectResponse
public function startLogIn($socialDriver)
{
$driver = $this->validateDriver($socialDriver);
return $this->getDriverForRedirect($driver)->redirect();
return $this->getSocialDriver($driver)->redirect();
}
/**
* Start the social registration process.
*
* Start the social registration process
* @param string $socialDriver
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* @throws SocialDriverNotConfigured
*/
public function startRegister(string $socialDriver): RedirectResponse
public function startRegister($socialDriver)
{
$driver = $this->validateDriver($socialDriver);
return $this->getDriverForRedirect($driver)->redirect();
return $this->getSocialDriver($driver)->redirect();
}
/**
* Handle the social registration process on callback.
*
* @param string $socialDriver
* @param SocialUser $socialUser
* @return SocialUser
* @throws UserRegistrationException
*/
public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser): SocialUser
public function handleRegistrationCallback(string $socialDriver, 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');
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount'=>$socialDriver, 'email' => $email]), '/login');
}
return $socialUser;
@@ -114,87 +79,85 @@ class SocialAuthService
/**
* Get the social user details via the social driver.
*
* @param string $socialDriver
* @return SocialUser
* @throws SocialDriverNotConfigured
*/
public function getSocialUser(string $socialDriver): SocialUser
public function getSocialUser(string $socialDriver)
{
$driver = $this->validateDriver($socialDriver);
return $this->socialite->driver($driver)->user();
}
/**
* Handle the login process on a oAuth callback.
*
* @param $socialDriver
* @param SocialUser $socialUser
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws SocialSignInAccountNotUsed
*/
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
public function handleLoginCallback($socialDriver, SocialUser $socialUser)
{
$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);
// 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);
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver]));
$this->fillSocialAccount($socialDriver, $socialUser);
$currentUser->socialAccounts()->save($this->socialAccount);
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => title_case($socialDriver)]));
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]));
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => title_case($socialDriver)]));
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]));
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => title_case($socialDriver)]));
return redirect($currentUser->getEditUrl());
}
// Otherwise let the user know this social account is not used by anyone.
$message = trans('errors.social_account_not_used', ['socialAccount' => $titleCaseDriver]);
if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') {
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]);
$message = trans('errors.social_account_not_used', ['socialAccount' => title_case($socialDriver)]);
if (setting('registration-enabled')) {
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => title_case($socialDriver)]);
}
throw new SocialSignInAccountNotUsed($message, '/login');
}
/**
* Ensure the social driver is correct and supported.
*
* @param $socialDriver
* @return string
* @throws SocialDriverNotConfigured
*/
protected function validateDriver(string $socialDriver): string
private function validateDriver($socialDriver)
{
$driver = trim(strtolower($socialDriver));
if (!in_array($driver, $this->validSocialDrivers)) {
abort(404, trans('errors.social_driver_not_found'));
}
if (!$this->checkDriverConfigured($driver)) {
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($socialDriver)]));
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => title_case($socialDriver)]));
}
return $driver;
@@ -202,114 +165,102 @@ class SocialAuthService
/**
* Check a social driver has been configured correctly.
* @param $driver
* @return bool
*/
protected function checkDriverConfigured(string $driver): bool
private function checkDriverConfigured($driver)
{
$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);
}
/**
* Gets the names of the active social drivers.
* @return array
*/
public function getActiveDrivers(): array
public function getActiveDrivers()
{
$activeDrivers = [];
foreach ($this->validSocialDrivers as $driverKey) {
if ($this->checkDriverConfigured($driverKey)) {
$activeDrivers[$driverKey] = $this->getDriverName($driverKey);
}
}
return $activeDrivers;
}
/**
* Get the presentational name for a driver.
* @param $driver
* @return mixed
*/
public function getDriverName(string $driver): string
public function getDriverName($driver)
{
return config('services.' . strtolower($driver) . '.name');
}
/**
* Check if the current config for the given driver allows auto-registration.
* @param string $driver
* @return bool
*/
public function driverAutoRegisterEnabled(string $driver): bool
public function driverAutoRegisterEnabled(string $driver)
{
return config('services.' . strtolower($driver) . '.auto_register') === true;
}
/**
* Check if the current config for the given driver allow email address auto-confirmation.
* @param string $driver
* @return bool
*/
public function driverAutoConfirmEmailEnabled(string $driver): bool
public function driverAutoConfirmEmailEnabled(string $driver)
{
return config('services.' . strtolower($driver) . '.auto_confirm') === true;
}
/**
* Fill and return a SocialAccount from the given driver name and SocialUser.
* @param string $socialDriver
* @param SocialUser $socialUser
* @return SocialAccount
*/
public function newSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
public function fillSocialAccount($socialDriver, $socialUser)
{
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.
* @param $socialDriver
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function detachSocialAccount(string $socialDriver): void
public function detachSocialAccount($socialDriver)
{
user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => title_case($socialDriver)]));
return redirect(user()->getEditUrl());
}
/**
* Provide redirect options per service for the Laravel Socialite driver.
* Provide redirect options per service for the Laravel Socialite driver
* @param $driverName
* @return \Laravel\Socialite\Contracts\Provider
*/
protected function getDriverForRedirect(string $driverName): Provider
public function getSocialDriver(string $driverName)
{
$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;
}
}
}

View File

@@ -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)
@@ -22,4 +19,5 @@ class UserInviteService extends UserTokenService
$token = $this->createTokenForUser($user);
$user->notify(new UserInvite($token));
}
}

View File

@@ -1,56 +1,58 @@
<?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\Support\Str;
use Illuminate\Database\Connection as Database;
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,76 +69,66 @@ 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);
$token = str_random(24);
while ($this->tokenExists($token)) {
$token = Str::random(25);
$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));
}
}
}

View File

@@ -1,42 +1,19 @@
<?php
<?php namespace BookStack\Auth\Permissions;
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property int $role_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', 'view', 'create', 'update', 'delete'];
protected $fillable = ['role_id', 'action'];
public $timestamps = false;
/**
* Get this restriction's attached entity.
* Get all this restriction's attached entity.
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function restrictable(): MorphTo
public function restrictable()
{
return $this->morphTo('restrictable');
}
/**
* Get the role assigned to this entity permission.
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
}

View File

@@ -1,141 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Builder;
class EntityPermissionEvaluator
{
protected string $action;
public function __construct(string $action)
{
$this->action = $action;
}
public function evaluateEntityForUser(Entity $entity, array $userRoleIds): ?bool
{
if ($this->isUserSystemAdmin($userRoleIds)) {
return true;
}
$typeIdChain = $this->gatherEntityChainTypeIds(SimpleEntityData::fromEntity($entity));
$relevantPermissions = $this->getPermissionsMapByTypeId($typeIdChain, [...$userRoleIds, 0]);
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
$status = $this->evaluatePermitsByType($permitsByType);
return is_null($status) ? null : $status === PermissionStatus::IMPLICIT_ALLOW || $status === PermissionStatus::EXPLICIT_ALLOW;
}
/**
* @param array<string, array<string, int>> $permitsByType
*/
protected function evaluatePermitsByType(array $permitsByType): ?int
{
// Return grant or reject from role-level if exists
if (count($permitsByType['role']) > 0) {
return max($permitsByType['role']) ? PermissionStatus::EXPLICIT_ALLOW : PermissionStatus::EXPLICIT_DENY;
}
// Return fallback permission if exists
if (count($permitsByType['fallback']) > 0) {
return $permitsByType['fallback'][0] ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
}
return null;
}
/**
* @param string[] $typeIdChain
* @param array<string, EntityPermission[]> $permissionMapByTypeId
* @return array<string, array<string, int>>
*/
protected function collapseAndCategorisePermissions(array $typeIdChain, array $permissionMapByTypeId): array
{
$permitsByType = ['fallback' => [], 'role' => []];
foreach ($typeIdChain as $typeId) {
$permissions = $permissionMapByTypeId[$typeId] ?? [];
foreach ($permissions as $permission) {
$roleId = $permission->role_id;
$type = $roleId === 0 ? 'fallback' : 'role';
if (!isset($permitsByType[$type][$roleId])) {
$permitsByType[$type][$roleId] = $permission->{$this->action};
}
}
if (isset($permitsByType['fallback'][0])) {
break;
}
}
return $permitsByType;
}
/**
* @param string[] $typeIdChain
* @return array<string, EntityPermission[]>
*/
protected function getPermissionsMapByTypeId(array $typeIdChain, array $filterRoleIds): array
{
$query = EntityPermission::query()->where(function (Builder $query) use ($typeIdChain) {
foreach ($typeIdChain as $typeId) {
$query->orWhere(function (Builder $query) use ($typeId) {
[$type, $id] = explode(':', $typeId);
$query->where('entity_type', '=', $type)
->where('entity_id', '=', $id);
});
}
});
if (!empty($filterRoleIds)) {
$query->where(function (Builder $query) use ($filterRoleIds) {
$query->whereIn('role_id', [...$filterRoleIds, 0]);
});
}
$relevantPermissions = $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
$map = [];
foreach ($relevantPermissions as $permission) {
$key = $permission->entity_type . ':' . $permission->entity_id;
if (!isset($map[$key])) {
$map[$key] = [];
}
$map[$key][] = $permission;
}
return $map;
}
/**
* @return string[]
*/
protected function gatherEntityChainTypeIds(SimpleEntityData $entity): array
{
// The array order here is very important due to the fact we walk up the chain
// elsewhere in the class. Earlier items in the chain have higher priority.
$chain = [$entity->type . ':' . $entity->id];
if ($entity->type === 'page' && $entity->chapter_id) {
$chain[] = 'chapter:' . $entity->chapter_id;
}
if ($entity->type === 'page' || $entity->type === 'chapter') {
$chain[] = 'book:' . $entity->book_id;
}
return $chain;
}
protected function isUserSystemAdmin($userRoleIds): bool
{
$adminRoleId = Role::getSystemRole('admin')->id;
return in_array($adminRoleId, $userRoleIds);
}
}

View File

@@ -1,30 +1,27 @@
<?php
namespace BookStack\Auth\Permissions;
<?php namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Entity;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphOne;
class JointPermission extends Model
{
protected $primaryKey = null;
public $timestamps = false;
/**
* Get the role that this points to.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function role(): BelongsTo
public function role()
{
return $this->belongsTo(Role::class);
}
/**
* Get the entity this points to.
* @return \Illuminate\Database\Eloquent\Relations\MorphOne
*/
public function entity(): MorphOne
public function entity()
{
return $this->morphOne(Entity::class, 'entity');
}

View File

@@ -1,292 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
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;
/**
* Joint permissions provide a pre-query "cached" table of view permissions for all core entity
* types for all roles in the system. This class generates out that table for different scenarios.
*/
class JointPermissionBuilder
{
/**
* Re-generate all entity permission from scratch.
*/
public function rebuildForAll()
{
JointPermission::query()->truncate();
// Get all roles (Should be the most limited dimension)
$roles = Role::query()->with('permissions')->get()->all();
// Chunk through all books
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
Bookshelf::query()->withTrashed()->select(['id', 'owned_by'])
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
$this->createManyJointPermissions($shelves->all(), $roles);
});
}
/**
* Rebuild the entity jointPermissions for a particular entity.
*/
public function rebuildForEntity(Entity $entity)
{
$entities = [$entity];
if ($entity instanceof Book) {
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
$this->buildJointPermissionsForBooks($books, Role::query()->with('permissions')->get()->all(), true);
return;
}
/** @var BookChild $entity */
if ($entity->book) {
$entities[] = $entity->book;
}
if ($entity instanceof Page && $entity->chapter_id) {
$entities[] = $entity->chapter;
}
if ($entity instanceof Chapter) {
foreach ($entity->pages as $page) {
$entities[] = $page;
}
}
$this->buildJointPermissionsForEntities($entities);
}
/**
* Build the entity jointPermissions for a particular role.
*/
public function rebuildForRole(Role $role)
{
$roles = [$role];
$role->jointPermissions()->delete();
$role->load('permissions');
// Chunk through all books
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
Bookshelf::query()->select(['id', 'owned_by'])
->chunk(50, function ($shelves) use ($roles) {
$this->createManyJointPermissions($shelves->all(), $roles);
});
}
/**
* Get a query for fetching a book with its children.
*/
protected function bookFetchQuery(): Builder
{
return Book::query()->withTrashed()
->select(['id', 'owned_by'])->with([
'chapters' => function ($query) {
$query->withTrashed()->select(['id', 'owned_by', 'book_id']);
},
'pages' => function ($query) {
$query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']);
},
]);
}
/**
* Build joint permissions for the given book and role combinations.
*/
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
{
$entities = clone $books;
/** @var Book $book */
foreach ($books->all() as $book) {
foreach ($book->getRelation('chapters') as $chapter) {
$entities->push($chapter);
}
foreach ($book->getRelation('pages') as $page) {
$entities->push($page);
}
}
if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($entities->all());
}
$this->createManyJointPermissions($entities->all(), $roles);
}
/**
* Rebuild the entity jointPermissions for a collection of entities.
*/
protected function buildJointPermissionsForEntities(array $entities)
{
$roles = Role::query()->get()->values()->all();
$this->deleteManyJointPermissionsForEntities($entities);
$this->createManyJointPermissions($entities, $roles);
}
/**
* Delete all the entity jointPermissions for a list of entities.
*
* @param Entity[] $entities
*/
protected function deleteManyJointPermissionsForEntities(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('joint_permissions')
->where('entity_type', '=', $type)
->whereIn('entity_id', $idChunk)
->delete();
}
}
});
}
/**
* @param Entity[] $entities
*
* @return SimpleEntityData[]
*/
protected function entitiesToSimpleEntities(array $entities): array
{
$simpleEntities = [];
foreach ($entities as $entity) {
$simple = SimpleEntityData::fromEntity($entity);
$simpleEntities[] = $simple;
}
return $simpleEntities;
}
/**
* Create & Save entity jointPermissions for many entities and roles.
*
* @param Entity[] $originalEntities
* @param Role[] $roles
*/
protected function createManyJointPermissions(array $originalEntities, array $roles)
{
$entities = $this->entitiesToSimpleEntities($originalEntities);
$jointPermissions = [];
// Fetch related entity permissions
$permissions = new MassEntityPermissionEvaluator($entities, 'view');
// 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) {
$jp = $this->createJointPermissionData(
$entity,
$role->getRawAttribute('id'),
$permissions,
$rolePermissionMap,
$role->system_name === 'admin'
);
$jointPermissions[] = $jp;
}
}
DB::transaction(function () use ($jointPermissions) {
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
DB::table('joint_permissions')->insert($jointPermissionChunk);
}
});
}
/**
* 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;
}
/**
* Create entity permission data for an entity and role
* for a particular action.
*/
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, MassEntityPermissionEvaluator $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
{
// Ensure system admin role retains permissions
if ($isAdminRole) {
return $this->createJointPermissionDataArray($entity, $roleId, PermissionStatus::EXPLICIT_ALLOW, true);
}
// Return evaluated entity permission status if it has an affect.
$entityPermissionStatus = $permissionMap->evaluateEntityForRole($entity, $roleId);
if ($entityPermissionStatus !== null) {
return $this->createJointPermissionDataArray($entity, $roleId, $entityPermissionStatus, false);
}
// Otherwise default to the role-level permissions
$permissionPrefix = $entity->type . '-view';
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
$status = $roleHasPermission ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
return $this->createJointPermissionDataArray($entity, $roleId, $status, $roleHasPermissionOwn);
}
/**
* Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion.
*/
protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, int $permissionStatus, bool $hasPermissionOwn): array
{
$ownPermissionActive = ($hasPermissionOwn && $permissionStatus !== PermissionStatus::EXPLICIT_DENY && $entity->owned_by);
return [
'entity_id' => $entity->id,
'entity_type' => $entity->type,
'role_id' => $roleId,
'status' => $permissionStatus,
'owner_id' => $ownPermissionActive ? $entity->owned_by : null,
];
}
}

View File

@@ -1,81 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
class MassEntityPermissionEvaluator extends EntityPermissionEvaluator
{
/**
* @var SimpleEntityData[]
*/
protected array $entitiesInvolved;
protected array $permissionMapCache;
public function __construct(array $entitiesInvolved, string $action)
{
$this->entitiesInvolved = $entitiesInvolved;
parent::__construct($action);
}
public function evaluateEntityForRole(SimpleEntityData $entity, int $roleId): ?int
{
$typeIdChain = $this->gatherEntityChainTypeIds($entity);
$relevantPermissions = $this->getPermissionMapByTypeIdForChainAndRole($typeIdChain, $roleId);
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
return $this->evaluatePermitsByType($permitsByType);
}
/**
* @param string[] $typeIdChain
* @return array<string, EntityPermission[]>
*/
protected function getPermissionMapByTypeIdForChainAndRole(array $typeIdChain, int $roleId): array
{
$allPermissions = $this->getPermissionMapByTypeIdAndRoleForAllInvolved();
$relevantPermissions = [];
// Filter down permissions to just those for current typeId
// and current roleID or fallback permissions.
foreach ($typeIdChain as $typeId) {
$relevantPermissions[$typeId] = [
...($allPermissions[$typeId][$roleId] ?? []),
...($allPermissions[$typeId][0] ?? [])
];
}
return $relevantPermissions;
}
/**
* @return array<string, array<int, EntityPermission[]>>
*/
protected function getPermissionMapByTypeIdAndRoleForAllInvolved(): array
{
if (isset($this->permissionMapCache)) {
return $this->permissionMapCache;
}
$entityTypeIdChain = [];
foreach ($this->entitiesInvolved as $entity) {
$entityTypeIdChain[] = $entity->type . ':' . $entity->id;
}
$permissionMap = $this->getPermissionsMapByTypeId($entityTypeIdChain, []);
// Manipulate permission map to also be keyed by roleId.
foreach ($permissionMap as $typeId => $permissions) {
$permissionMap[$typeId] = [];
foreach ($permissions as $permission) {
$roleId = $permission->getRawAttribute('role_id');
if (!isset($permissionMap[$typeId][$roleId])) {
$permissionMap[$typeId][$roleId] = [];
}
$permissionMap[$typeId][$roleId][] = $permission;
}
}
$this->permissionMapCache = $permissionMap;
return $this->permissionMapCache;
}
}

View File

@@ -1,198 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Auth\User;
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 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, $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, string $action): ?bool
{
$this->ensureValidEntityAction($action);
return (new EntityPermissionEvaluator($action))->evaluateEntityForUser($entity, $userRoleIds);
}
/**
* 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)
->whereIn('role_id', $this->getCurrentUserRoleIds());
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): Builder
{
return $query->where(function (Builder $parentQuery) {
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) {
$permissionQuery->select(['entity_id', 'entity_type'])
->selectRaw('max(owner_id) as owner_id')
->selectRaw('max(status) as status')
->whereIn('role_id', $this->getCurrentUserRoleIds())
->groupBy(['entity_type', 'entity_id'])
->havingRaw('(status IN (1, 3) or (owner_id = ? and status != 2))', [$this->currentUser()->id]);
});
});
}
/**
* 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.
*/
public function restrictEntityRelationQuery(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn): Builder
{
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$pageMorphClass = (new Page())->getMorphClass();
return $this->restrictEntityQuery($query)
->where(function ($query) use ($tableDetails, $pageMorphClass) {
/** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
->where('pages.draft', '=', false);
});
});
}
/**
* 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
{
$fullPageIdColumn = $tableName . '.' . $pageIdColumn;
return $this->restrictEntityQuery($query)
->where(function ($query) use ($fullPageIdColumn) {
/** @var Builder $query */
$query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullPageIdColumn)
->where('pages.draft', '=', false);
});
});
}
/**
* 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');
}
}
}

View File

@@ -1,68 +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')
->where('role_id', '!=', 0)
->get()
->sortBy('role.display_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()->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()
->where('role_id', '=', 0)
->first();
return $permission ?? (new EntityPermission());
}
/**
* Get the "Everyone Else" role entry.
*/
public function everyoneElseRole(): Role
{
return (new Role())->forceFill([
'id' => 0,
'display_name' => trans('entities.permissions_role_everyone_else'),
'description' => trans('entities.permissions_role_everyone_else_desc'),
]);
}
}

View File

@@ -0,0 +1,795 @@
<?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
* @throws \Throwable
*/
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;
}
/**
* Get the children of a book in an efficient single query, Filtered by the permission system.
* @param integer $book_id
* @param bool $filterDrafts
* @param bool $fetchPageContent
* @return QueryBuilder
*/
public function bookChildrenQuery($book_id, $filterDrafts = false, $fetchPageContent = false)
{
$entities = $this->entityProvider;
$pageSelect = $this->db->table('pages')->selectRaw($entities->page->entityRawQuery($fetchPageContent))
->where('book_id', '=', $book_id)->where(function ($query) use ($filterDrafts) {
$query->where('draft', '=', 0);
if (!$filterDrafts) {
$query->orWhere(function ($query) {
$query->where('draft', '=', 1)->where('created_by', '=', $this->currentUser()->id);
});
}
});
$chapterSelect = $this->db->table('chapters')->selectRaw($entities->chapter->entityRawQuery())->where('book_id', '=', $book_id);
$query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U"))
->mergeBindings($pageSelect)->mergeBindings($chapterSelect);
// Add joint permission filter
$whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)')
->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type')
->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles())
->where(function ($query) {
$query->where('jp.has_permission', '=', 1)->orWhere(function ($query) {
$query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id);
});
});
$query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery);
$query->orderBy('draft', 'desc')->orderBy('priority', 'asc');
$this->clean();
return $query;
}
/**
* 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);
if ($this->currentUser()) {
$query->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;
}
}

View File

@@ -1,11 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
class PermissionStatus
{
const IMPLICIT_DENY = 0;
const IMPLICIT_ALLOW = 1;
const EXPLICIT_DENY = 2;
const EXPLICIT_ALLOW = 3;
}

View File

@@ -1,76 +1,91 @@
<?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;
class PermissionsRepo
{
protected JointPermissionBuilder $permissionBuilder;
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(JointPermissionBuilder $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);
$this->permissionBuilder->rebuildForRole($role);
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') {
@@ -86,28 +101,22 @@ class PermissionsRepo
$this->assignRolePermissions($role, $permissions);
$role->fill($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->save();
$this->permissionBuilder->rebuildForRole($role);
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);
}
@@ -116,32 +125,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 == 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->jointPermissions()->delete();
Activity::add(ActivityType::ROLE_DELETE, $role);
$this->permissionService->deleteJointPermissionsForRole($role);
$role->delete();
}
}

View File

@@ -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();
}

View File

@@ -1,28 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Entities\Models\Entity;
class SimpleEntityData
{
public int $id;
public string $type;
public int $owned_by;
public ?int $book_id;
public ?int $chapter_id;
public static function fromEntity(Entity $entity): self
{
$attrs = $entity->getAttributes();
$simple = new self();
$simple->id = $attrs['id'];
$simple->type = $entity->getMorphClass();
$simple->owned_by = $attrs['owned_by'] ?? 0;
$simple->book_id = $attrs['book_id'] ?? null;
$simple->chapter_id = $attrs['chapter_id'] ?? null;
return $simple;
}
}

View File

@@ -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);
}
}

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -1,48 +1,27 @@
<?php
<?php namespace BookStack\Auth;
namespace BookStack\Auth;
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 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(): HasMany
public function jointPermissions()
{
return $this->hasMany(JointPermission::class);
}
@@ -50,23 +29,17 @@ class Role extends Model implements Loggable
/**
* 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);
}
/**
* 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 +47,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,33 +61,39 @@ class Role extends Model implements Loggable
/**
* Detach a single permission from this role.
* @param RolePermission $permission
*/
public function detachPermission(RolePermission $permission)
{
$this->permissions()->detach([$permission->id]);
$this->permissions()->detach($permission->id);
}
/**
* 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::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::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::where('hidden', '=', false)->orderBy('name')->get();
}
}

View File

@@ -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()}";
}
}

View File

@@ -1,212 +1,162 @@
<?php
<?php namespace BookStack\Auth;
namespace BookStack\Auth;
use BookStack\Actions\Favourite;
use BookStack\Api\ApiToken;
use BookStack\Auth\Access\Mfa\MfaValue;
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'];
/**
* 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 = '';
/**
* This holds the default user when loaded.
*
* @var null|User
*/
protected static ?User $defaultUser = null;
protected $permissions;
/**
* 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();
return static::$defaultUser;
return static::where('system_name', '=', 'public')->first();
}
/**
* 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);
}
/**
* Attach the default system role to this user.
* Get all permissions belonging to a the current user.
* @param bool $cache
* @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
*/
public function attachDefaultRole(): void
public function permissions($cache = true)
{
$roleId = intval(setting('registration-role'));
if ($roleId && $this->roles()->where('id', '=', $roleId)->count() === 0) {
$this->roles()->attach($roleId);
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(string $permissionName): 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)
{
$this->roles()->attach($role->id);
$this->unsetRelation('roles');
$this->attachRoleId($role->id);
}
/**
* Attach a role id to this user.
* @param $id
*/
public function attachRoleId($id)
{
$this->roles()->attach($id);
}
/**
* 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);
}
@@ -214,9 +164,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)
@@ -229,9 +177,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;
@@ -239,88 +189,47 @@ 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');
}
/**
* Get the API tokens assigned to this user.
*/
public function apiTokens(): HasMany
{
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 url for editing this user.
* @return string
*/
public function getEditUrl(string $path = ''): string
public function getEditUrl()
{
$uri = '/settings/users/' . $this->id . '/' . trim($path, '/');
return url(rtrim($uri, '/'));
return url('/settings/users/' . $this->id);
}
/**
* Get the url that links to this user's profile.
* @return mixed
*/
public function getProfileUrl(): string
public function getProfileUrl()
{
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;
@@ -336,31 +245,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;
}
}

View File

@@ -1,241 +1,150 @@
<?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\Repos\EntityRepo;
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;
class UserRepo
{
protected UserAvatars $userAvatar;
protected UserInviteService $inviteService;
protected $user;
protected $role;
protected $entityRepo;
/**
* UserRepo constructor.
* @param User $user
* @param Role $role
* @param EntityRepo $entityRepo
*/
public function __construct(UserAvatars $userAvatar, UserInviteService $inviteService)
public function __construct(User $user, Role $role, EntityRepo $entityRepo)
{
$this->userAvatar = $userAvatar;
$this->inviteService = $inviteService;
$this->user = $user;
$this->role = $role;
$this->entityRepo = $entityRepo;
}
/**
* Get a user by their email address.
* @param string $email
* @return User|null
*/
public function getByEmail(string $email): ?User
public function getByEmail($email)
{
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.
* @param array $data
* @param boolean $verifyEmail
* @return \BookStack\Auth\User
*/
public function registerNew(array $data, $verifyEmail = false)
{
$user = $this->create($data, $verifyEmail);
$this->attachDefaultRole($user);
$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
* Give a user the default role. Used when creating a new user.
* @param User $user
*/
public function create(array $data, bool $sendInvite = false): User
public function attachDefaultRole(User $user)
{
$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->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());
$roleId = setting('registration-role');
if ($roleId !== false && $user->roles()->where('id', '=', $roleId)->count() === 0) {
$user->attachRoleId($roleId);
}
}
/**
* Migrate ownership of items in the system from one user to another.
* Assign a user to a system-level role.
* @param User $user
* @param $systemRoleName
* @throws NotFoundException
*/
protected function migrateOwnership(User $fromUser, User $toUser)
public function attachSystemRole(User $user, $systemRoleName)
{
$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 \BookStack\Auth\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)
{
$roles = array_filter(array_values($roles));
if ($this->demotingLastAdmin($user, $roles)) {
throw new UserUpdateException(trans('errors.role_cannot_remove_only_admin'), $user->getEditUrl());
}
@@ -246,11 +155,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 +170,126 @@ class UserRepo
return false;
}
/**
* Create a new basic instance of user.
* @param array $data
* @param boolean $verifyEmail
* @return \BookStack\Auth\User
*/
public function create(array $data, $verifyEmail = false)
{
return $this->user->forceCreate([
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
'email_confirmed' => $verifyEmail
]);
}
/**
* Remove the given user from storage, Delete all related content.
* @param \BookStack\Auth\User $user
* @throws Exception
*/
public function destroy(User $user)
{
$user->socialAccounts()->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 \BookStack\Auth\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.
* @param \BookStack\Auth\User $user
* @param int $count
* @return mixed
*/
public function getRecentlyCreated(User $user, $count = 20)
{
$createdByUserQuery = function (Builder $query) use ($user) {
$query->where('created_by', '=', $user->id);
};
return [
'pages' => $this->entityRepo->getRecentlyCreated('page', $count, 0, $createdByUserQuery),
'chapters' => $this->entityRepo->getRecentlyCreated('chapter', $count, 0, $createdByUserQuery),
'books' => $this->entityRepo->getRecentlyCreated('book', $count, 0, $createdByUserQuery),
'shelves' => $this->entityRepo->getRecentlyCreated('bookshelf', $count, 0, $createdByUserQuery)
];
}
/**
* Get asset created counts for the give user.
* @param \BookStack\Auth\User $user
* @return array
*/
public function getAssetCounts(User $user)
{
return [
'pages' => $this->entityRepo->getUserTotalCreated('page', $user),
'chapters' => $this->entityRepo->getUserTotalCreated('chapter', $user),
'books' => $this->entityRepo->getUserTotalCreated('book', $user),
'shelves' => $this->entityRepo->getUserTotalCreated('bookshelf', $user),
];
}
/**
* 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 all the roles which can be given restricted access to
* other entities in the system.
* @return mixed
*/
public function getRestrictableRoles()
{
return $this->role->where('system_name', '!=', 'admin')->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;
}
}
}

View File

@@ -1,23 +0,0 @@
<?php
/**
* API configuration options.
*
* Changes to these config files are not supported by BookStack and may break upon updates.
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
return [
// The default number of items that are returned in listing API requests.
// This count can often be overridden, up the the max option, per-request via request options.
'default_item_count' => env('API_DEFAULT_ITEM_COUNT', 100),
// The maximum number of items that can be returned in a listing API request.
'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),
];

158
app/Config/app.php Normal file → Executable file
View 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,14 +52,11 @@ 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', 'de', 'de_informal', 'es', 'es_AR', 'fr', 'hu', 'nl', 'pt_BR', 'sk', 'cs', 'sv', 'kr', 'ja', 'pl', 'it', 'ru', 'uk', 'zh_CN', 'zh_TW'],
// Application Fallback Locale
'fallback_locale' => 'en',
// Faker Locale
'faker_locale' => 'en_GB',
// Enable right-to-left text control.
'rtl' => false,
@@ -98,6 +72,10 @@ return [
// Encryption cipher
'cipher' => 'AES-256-CBC',
// Logging configuration
// Options: single, daily, syslog, errorlog
'log' => env('APP_LOGGING', 'single'),
// Application Services Provides
'providers' => [
@@ -114,8 +92,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 +99,26 @@ 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 +134,54 @@ 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,
'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,
'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,
],
// Proxy configuration

View File

@@ -10,18 +10,15 @@
return [
// Options: standard, ldap, saml2, oidc
// Method of authentication to use
// Options: standard, ldap
'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' => 'web',
'passwords' => 'users',
],
@@ -29,26 +26,16 @@ 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: "session", "token"
'guards' => [
'standard' => [
'driver' => 'session',
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'ldap' => [
'driver' => 'ldap-session',
'provider' => 'external',
],
'saml2' => [
'driver' => 'async-external-session',
'provider' => 'external',
],
'oidc' => [
'driver' => 'async-external-session',
'provider' => 'external',
],
'api' => [
'driver' => 'api-token',
'driver' => 'token',
'provider' => 'users',
],
],
@@ -56,15 +43,11 @@ 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: database, eloquent, ldap
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => \BookStack\Auth\User::class,
],
'external' => [
'driver' => 'external-users',
'model' => \BookStack\Auth\User::class,
'driver' => env('AUTH_METHOD', 'standard') === 'standard' ? 'eloquent' : env('AUTH_METHOD'),
'model' => \BookStack\Auth\User::class,
],
// 'users' => [
@@ -80,17 +63,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,
];
];

View File

@@ -23,18 +23,14 @@ return [
'connections' => [
'pusher' => [
'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,
],
'driver' => 'pusher',
'key' => env('PUSHER_KEY'),
'secret' => env('PUSHER_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
],
'redis' => [
'driver' => 'redis',
'driver' => 'redis',
'connection' => 'default',
],
@@ -42,10 +38,6 @@ return [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

View File

@@ -1,7 +1,5 @@
<?php
use Illuminate\Support\Str;
/**
* Caching configuration options.
*
@@ -16,12 +14,8 @@ if (env('CACHE_DRIVER') === 'memcached') {
$memcachedServers = explode(',', trim(env('MEMCACHED_SERVERS', '127.0.0.1:11211:100'), ','));
foreach ($memcachedServers as $index => $memcachedServer) {
$memcachedServerDetails = explode(':', $memcachedServer);
if (count($memcachedServerDetails) < 2) {
$memcachedServerDetails[] = '11211';
}
if (count($memcachedServerDetails) < 3) {
$memcachedServerDetails[] = '100';
}
if (count($memcachedServerDetails) < 2) $memcachedServerDetails[] = '11211';
if (count($memcachedServerDetails) < 3) $memcachedServerDetails[] = '100';
$memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails);
}
}
@@ -40,15 +34,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 +49,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'),
];

View File

@@ -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,
];

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