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
1443 changed files with 30019 additions and 95950 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 # Application key
# Used for encryption where needed. # Used for encryption where needed.
# Run `php artisan key:generate` to generate a valid key. # Run `php artisan key:generate` to generate a valid key.
APP_KEY=SomeRandomString APP_KEY=SomeRandomString
# Application URL # 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. # This must be the root URL that you want to host BookStack on.
# All URLs in BookStack will be generated using this value # All URL's in BookStack will be generated using this value.
# to ensure URLs generated are consistent and secure. #APP_URL=https://example.com
# 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
# Database details # Database details
DB_HOST=localhost DB_HOST=localhost
@@ -27,18 +17,15 @@ DB_USERNAME=database_username
DB_PASSWORD=database_user_password DB_PASSWORD=database_user_password
# Mail system to use # Mail system to use
# Can be 'smtp' or 'sendmail' # Can be 'smtp', 'mail' or 'sendmail'
MAIL_DRIVER=smtp MAIL_DRIVER=smtp
# Mail sender details
MAIL_FROM_NAME="BookStack"
MAIL_FROM=bookstack@example.com
# SMTP mail options # 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_HOST=localhost
MAIL_PORT=1025 MAIL_PORT=1025
MAIL_USERNAME=null MAIL_USERNAME=null
MAIL_PASSWORD=null MAIL_PASSWORD=null
MAIL_ENCRYPTION=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 # Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
APP_TIMEZONE=UTC 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 # Database details
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used. # Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
DB_HOST=localhost DB_HOST=localhost
@@ -59,7 +46,7 @@ DB_USERNAME=database_username
DB_PASSWORD=database_user_password DB_PASSWORD=database_user_password
# Mail system to use # Mail system to use
# Can be 'smtp' or 'sendmail' # Can be 'smtp', 'mail' or 'sendmail'
MAIL_DRIVER=smtp MAIL_DRIVER=smtp
# Mail sending options # Mail sending options
@@ -100,8 +87,9 @@ MEMCACHED_SERVERS=127.0.0.1:11211:100
REDIS_SERVERS=127.0.0.1:6379:0 REDIS_SERVERS=127.0.0.1:6379:0
# Queue driver to use # Queue driver to use
# Can be 'sync', 'database' or 'redis' # Queue not really currently used but may be configurable in the future.
QUEUE_CONNECTION=sync # Would advise not to change this for now.
QUEUE_DRIVER=sync
# Storage system to use # Storage system to use
# Can be 'local', 'local_secure' or 's3' # Can be 'local', 'local_secure' or 's3'
@@ -133,7 +121,7 @@ STORAGE_S3_ENDPOINT=https://my-custom-s3-compatible.service.com:8001
STORAGE_URL=false STORAGE_URL=false
# Authentication method to use # Authentication method to use
# Can be 'standard', 'ldap', 'saml2' or 'oidc' # Can be 'standard' or 'ldap'
AUTH_METHOD=standard AUTH_METHOD=standard
# Social authentication configuration # Social authentication configuration
@@ -202,14 +190,10 @@ LDAP_DN=false
LDAP_PASS=false LDAP_PASS=false
LDAP_USER_FILTER=false LDAP_USER_FILTER=false
LDAP_VERSION=false LDAP_VERSION=false
LDAP_START_TLS=false
LDAP_TLS_INSECURE=false LDAP_TLS_INSECURE=false
LDAP_ID_ATTRIBUTE=uid
LDAP_EMAIL_ATTRIBUTE=mail LDAP_EMAIL_ATTRIBUTE=mail
LDAP_DISPLAY_NAME_ATTRIBUTE=cn LDAP_DISPLAY_NAME_ATTRIBUTE=cn
LDAP_THUMBNAIL_ATTRIBUTE=null
LDAP_FOLLOW_REFERRALS=true LDAP_FOLLOW_REFERRALS=true
LDAP_DUMP_USER_DETAILS=false
# LDAP group sync configuration # LDAP group sync configuration
# Refer to https://www.bookstackapp.com/docs/admin/ldap-auth/ # Refer to https://www.bookstackapp.com/docs/admin/ldap-auth/
@@ -217,42 +201,6 @@ LDAP_USER_TO_GROUPS=false
LDAP_GROUP_ATTRIBUTE="memberOf" LDAP_GROUP_ATTRIBUTE="memberOf"
LDAP_REMOVE_FROM_GROUPS=false LDAP_REMOVE_FROM_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_DUMP_USER_DETAILS=false
# Disable default third-party services such as Gravatar and Draw.IO # Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option # Service-specific options will override this option
DISABLE_EXTERNAL_SERVICES=false DISABLE_EXTERNAL_SERVICES=false
@@ -263,40 +211,20 @@ DISABLE_EXTERNAL_SERVICES=false
# Example: AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon # Example: AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon
AVATAR_URL= AVATAR_URL=
# Enable diagrams.net integration # Enable Draw.io 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
DRAWIO=true DRAWIO=true
# Default item listing view # Default item listing view
# Used for public visitors and user's without a preference. # Used for public visitors and user's without a preference
# Can be 'list' or 'grid'. # Can be 'list' or 'grid'
APP_VIEWS_BOOKS=list APP_VIEWS_BOOKS=list
APP_VIEWS_BOOKSHELVES=grid 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 # Page revision limit
# Number of page revisions to keep in the system before deleting old revisions. # Number of page revisions to keep in the system before deleting old revisions.
# If set to 'false' a limit will not be enforced. # If set to 'false' a limit will not be enforced.
REVISION_LIMIT=50 REVISION_LIMIT=50
# 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
# Allow <script> tags in page content # Allow <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts. # Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false ALLOW_CONTENT_SCRIPTS=false
@@ -307,29 +235,3 @@ ALLOW_CONTENT_SCRIPTS=false
# Contents of the robots.txt file can be overridden, making this option obsolete. # Contents of the robots.txt file can be overridden, making this option obsolete.
ALLOW_ROBOTS=null 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
# 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

3
.github/FUNDING.yml vendored
View File

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

View File

@@ -1,26 +0,0 @@
name: New API Endpoint or API Ability
description: Request a new endpoint or API feature be added
title: "[API Request]: "
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,62 +0,0 @@
name: Bug Report
description: Create a report to help us improve or fix things
title: "[Bug Report]: "
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: 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,9 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Discord chat support
url: https://discord.gg/ztkBqR2
about: Realtime support / chat with the community and the team.
- name: Debugging & Common Issues
url: https://www.bookstackapp.com/docs/admin/debugging/
about: Find details on how to debug issues and view common issues with thier resolutions.

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,26 +0,0 @@
name: Feature Request
description: Request a new language to be added to CrowdIn for you to translate
title: "[Feature Request]: "
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 feature would bring to BookStack users
description: Explain the measurable benefits this feature would achieve for existing BookStack users
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,32 +0,0 @@
name: Language Request
description: Request a new language to be added to CrowdIn for you to translate
title: "[Language Request]: "
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,63 +0,0 @@
name: Support Request
description: Request support for a specific problem you have not been able to solve yourself
title: "[Support Request]: "
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,212 +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
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
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; German Informal
kometchtech :: Japanese
Auri (Atalonica) :: Catalan
Francesco Franchina (ffranchina) :: Italian
Aimrane Kds (aimrane.kds) :: Arabic
whenwesober :: Indonesian
Rem (remkovdhoef) :: Dutch
syn7ax69 :: Bulgarian; Turkish
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 :: 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

View File

@@ -1,41 +0,0 @@
name: phpstan
on:
push:
branches-ignore:
- l10n_master
pull_request:
branches-ignore:
- l10n_master
jobs:
build:
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.3']
steps:
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v1
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
- name: Install composer dependencies
run: composer install --prefer-dist --no-interaction --ansi
- name: Run PHPStan
run: php${{ matrix.php }} ./vendor/bin/phpstan analyse --memory-limit=2G

View File

@@ -1,57 +0,0 @@
name: phpunit
on:
push:
branches-ignore:
- l10n_master
pull_request:
branches-ignore:
- l10n_master
jobs:
build:
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.3', '7.4', '8.0', '8.1']
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 "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v1
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
- 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: phpunit
run: php${{ matrix.php }} ./vendor/bin/phpunit

View File

@@ -1,61 +0,0 @@
name: test-migrations
on:
push:
branches-ignore:
- l10n_master
pull_request:
branches-ignore:
- l10n_master
jobs:
build:
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.3', '7.4', '8.0', '8.1']
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 "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v1
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
- 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

5
.gitignore vendored
View File

@@ -21,7 +21,4 @@ nbproject
.buildpath .buildpath
.project .project
.settings/ .settings/
webpack-stats.json webpack-stats.json
.phpunit.result.cache
.DS_Store
phpstan.neon

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,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2015-present, Dan Brown and the BookStack Project contributors Copyright (c) 2018 Dan Brown and the BookStack Project contributors
https://github.com/BookStackApp/BookStack/graphs/contributors https://github.com/BookStackApp/BookStack/graphs/contributors
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy

49
TODO
View File

@@ -1,49 +0,0 @@
### Next
- Table cell height resize & cell width resize via width style
- Column resize source: https://github.com/ProseMirror/prosemirror-tables/blob/master/src/columnresizing.js
- Have updated column resizing to set cell widths
- Now need to handle table overall size on change, then heights.
- Details/Summary
- Need view to control summary editability, make readonly but editable via popover.
- Need some default styles to visualise details boundary.
- Markdown parser needs to be updated to handle separate open/close tags for blocks.
### In-Progress
- Tables
- Details/Summary
### Features
- Images
- Drawings
- LTR/RTL control
- Fullscreen
- Paste Image Uploading
- Drag + Drop Image Uploading
- Checkbox/TODO list items
- Code blocks
- Indents
- Attachment integration (Drag & drop)
- Template system integration.
### Improvements
- List type changing.
- Color picker options should have "clear" option.
- Color picker buttons should be split, with button to re-apply last selected color.
- Color picker options should change color if different instead of remove.
- Clear formatting, If no selection range, clear the formatting of parent block.
- If no marks, clear the block type if text type?
- Remove links button? (Action already in place if link href is empty).
- Links - Validate URL.
- Links - Integrate entity picker.
- iFrame - Parse iframe HTML & auto-convert youtube/vimeo urls to embeds.
### Notes
- Use NodeViews for embedded content (Code, Drawings) where control is needed.
- Probably still easiest to have seperate (codemirror) MD editor. Can alter display output via NodeViews to make MD like
but its tricky since editing the markdown content would change the block definition/type while editing.

View File

@@ -3,66 +3,53 @@
namespace BookStack\Actions; namespace BookStack\Actions;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Model; use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Str;
/** /**
* @property string $type * @property string key
* @property User $user * @property \User user
* @property Entity $entity * @property \Entity entity
* @property string $detail * @property string extra
* @property string $entity_type
* @property int $entity_id
* @property int $user_id
*/ */
class Activity extends Model class Activity extends Model
{ {
/** /**
* Get the entity for this activity. * Get the entity for this activity.
*/ */
public function entity(): MorphTo public function entity()
{ {
if ($this->entity_type === '') { if ($this->entity_type === '') {
$this->entity_type = null; $this->entity_type = null;
} }
return $this->morphTo('entity'); return $this->morphTo('entity');
} }
/** /**
* Get the user this activity relates to. * 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); return $this->belongsTo(User::class);
} }
/** /**
* Returns text from the language files, Looks up by using the activity key. * Returns text from the language files, Looks up by using the
* activity key.
*/ */
public function getText(): string public function getText()
{ {
return trans('activities.' . $this->type); return trans('activities.' . $this->key);
}
/**
* Check if this activity is intended to be for an entity.
*/
public function isForEntity(): bool
{
return Str::startsWith($this->type, [
'page_', 'chapter_', 'book_', 'bookshelf_',
]);
} }
/** /**
* Checks if another Activity matches the general information of another. * 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,115 +0,0 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
class ActivityLogger
{
protected $permissionService;
public function __construct(PermissionService $permissionService)
{
$this->permissionService = $permissionService;
}
/**
* Add a generic activity event to the database.
*
* @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);
}
/**
* Get a new activity instance for the current user.
*/
protected function newActivityForUser(string $type): Activity
{
$ip = request()->ip() ?? '';
return (new Activity())->forceFill([
'type' => strtolower($type),
'user_id' => user()->id,
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
]);
}
/**
* Removes the entity attachment from each of its activities
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
*/
public function removeEntity(Entity $entity)
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
]);
}
/**
* 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\PermissionService;
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 $permissionService;
public function __construct(PermissionService $permissionService)
{
$this->permissionService = $permissionService;
}
/**
* Gets the latest activity.
*/
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations(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->permissionService
->filterRestrictedEntityRelations(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,68 +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_UPDATE = 'book_update';
const BOOK_DELETE = 'book_delete';
const BOOK_SORT = 'book_sort';
const BOOKSHELF_CREATE = 'bookshelf_create';
const BOOKSHELF_UPDATE = 'bookshelf_update';
const BOOKSHELF_DELETE = 'bookshelf_delete';
const COMMENTED_ON = 'commented_on';
const PERMISSIONS_UPDATE = 'permissions_update';
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; class Comment extends Ownable
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
{ {
use HasFactory; protected $fillable = ['text', 'html', 'parent_id'];
use HasCreatorAndUpdater;
protected $fillable = ['text', 'parent_id'];
protected $appends = ['created', 'updated']; 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'); return $this->morphTo('entity');
} }
/** /**
* Check if a comment has been updated since creation. * 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; return $this->updated_at->timestamp > $this->created_at->timestamp;
} }
/** /**
* Get created date as a relative diff. * Get created date as a relative diff.
*
* @return mixed * @return mixed
*/ */
public function getCreatedAttribute() public function getCreatedAttribute()
@@ -50,7 +36,6 @@ class Comment extends Model
/** /**
* Get updated date as a relative diff. * Get updated date as a relative diff.
*
* @return mixed * @return mixed
*/ */
public function getUpdatedAttribute() public function getUpdatedAttribute()

View File

@@ -1,21 +1,23 @@
<?php <?php namespace BookStack\Actions;
namespace BookStack\Actions; use BookStack\Entities\Entity;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Activity as ActivityService;
use League\CommonMark\CommonMarkConverter;
/** /**
* Class CommentRepo. * Class CommentRepo
* @package BookStack\Repos
*/ */
class CommentRepo class CommentRepo
{ {
/** /**
* @var Comment * @var \BookStack\Actions\Comment $comment
*/ */
protected $comment; protected $comment;
/**
* CommentRepo constructor.
* @param \BookStack\Actions\Comment $comment
*/
public function __construct(Comment $comment) public function __construct(Comment $comment)
{ {
$this->comment = $comment; $this->comment = $comment;
@@ -23,76 +25,65 @@ class CommentRepo
/** /**
* Get a comment by ID. * 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); return $this->comment->newQuery()->findOrFail($id);
} }
/** /**
* Create a new comment on an entity. * 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; $userId = user()->id;
$comment = $this->comment->newInstance(); $comment = $this->comment->newInstance($data);
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
$comment->created_by = $userId; $comment->created_by = $userId;
$comment->updated_by = $userId; $comment->updated_by = $userId;
$comment->local_id = $this->getNextLocalId($entity); $comment->local_id = $this->getNextLocalId($entity);
$comment->parent_id = $parent_id;
$entity->comments()->save($comment); $entity->comments()->save($comment);
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
return $comment; return $comment;
} }
/** /**
* Update an existing 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->updated_by = user()->id;
$comment->text = $text; $comment->update($input);
$comment->html = $this->commentToHtml($text);
$comment->save();
return $comment; return $comment;
} }
/** /**
* Delete a comment from the system. * 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(); return $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);
} }
/** /**
* Get the next local ID relative to the linked entity. * 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 */ $comments = $entity->comments(false)->orderBy('local_id', 'desc')->first();
$comment = $entity->comments(false)->orderBy('local_id', 'desc')->first(); if ($comments === null) {
return 1;
return ($comment->local_id ?? 0) + 1; }
return $comments->local_id + 1;
} }
} }

View File

@@ -1,132 +0,0 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Theme;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use BookStack\Theming\ThemeEvents;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class DispatchWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* @var Webhook
*/
protected $webhook;
/**
* @var string
*/
protected $event;
/**
* @var string|Loggable
*/
protected $detail;
/**
* @var User
*/
protected $initiator;
/**
* @var int
*/
protected $initiatedTime;
/**
* Create a new job instance.
*
* @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);
$webhookData = $themeResponse ?? $this->buildWebhookData();
$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();
}
protected function buildWebhookData(): array
{
$textParts = [
$this->initiator->name,
trans('activities.' . $this->event),
];
if ($this->detail instanceof Entity) {
$textParts[] = '"' . $this->detail->name . '"';
}
$data = [
'event' => $this->event,
'text' => implode(' ', $textParts),
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
'triggered_by' => $this->initiator->attributesToArray(),
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
'webhook_id' => $this->webhook->id,
'webhook_name' => $this->webhook->name,
];
if (method_exists($this->detail, 'getUrl')) {
$data['url'] = $this->detail->getUrl();
}
if ($this->detail instanceof Model) {
$data['related_item'] = $this->detail->attributesToArray();
}
return $data;
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace BookStack\Actions;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Favourite extends Model
{
protected $fillable = ['user_id'];
/**
* Get the related model that can be favourited.
*/
public function favouritable(): MorphTo
{
return $this->morphTo();
}
}

View File

@@ -1,45 +1,21 @@
<?php <?php namespace BookStack\Actions;
namespace BookStack\Actions;
use BookStack\Model; use BookStack\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/** /**
* @property int $id * Class Attribute
* @property string $name * @package BookStack
* @property string $value
* @property int $order
*/ */
class Tag extends Model class Tag extends Model
{ {
use HasFactory;
protected $fillable = ['name', 'value', 'order']; protected $fillable = ['name', 'value', 'order'];
protected $hidden = ['id', 'entity_id', 'entity_type', 'created_at', 'updated_at'];
/** /**
* 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'); return $this->morphTo('entity');
} }
/**
* Get a full URL to start a tag name search for this tag name.
*/
public function nameUrl(): string
{
return url('/search?term=%5B' . urlencode($this->name) . '%5D');
}
/**
* Get a full URL to start a tag name and value search for this tag's values.
*/
public function valueUrl(): string
{
return url('/search?term=%5B' . urlencode($this->name) . '%3D' . urlencode($this->value) . '%5D');
}
} }

View File

@@ -1,68 +1,72 @@
<?php <?php namespace BookStack\Actions;
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService; use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Entity;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Class TagRepo
* @package BookStack\Repos
*/
class TagRepo class TagRepo
{ {
protected $tag; protected $tag;
protected $entity;
protected $permissionService; protected $permissionService;
public function __construct(PermissionService $ps) /**
* 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->tag = $attr;
$this->entity = $ent;
$this->permissionService = $ps; $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(string $searchTerm, string $nameFilter): Builder public function getEntity($entityType, $entityId, $action = 'view')
{ {
$query = Tag::query() $entityInstance = $this->entity->getEntityInstance($entityType);
->select([ $searchQuery = $entityInstance->where('id', '=', $entityId)->with('tags');
'name', $searchQuery = $this->permissionService->enforceEntityRestrictions($entityType, $searchQuery, $action);
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'), return $searchQuery->first();
DB::raw('COUNT(id) as usages'), }
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Page\', 1, 0)) as page_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Chapter\', 1, 0)) as chapter_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\BookShelf\', 1, 0)) as shelf_count'),
])
->orderBy($nameFilter ? 'value' : 'name');
if ($nameFilter) { /**
$query->where('name', '=', $nameFilter); * Get all tags for a particular entity.
$query->groupBy('value'); * @param string $entityType
} elseif ($searchTerm) { * @param int $entityId
$query->groupBy('name', 'value'); * @return mixed
} else { */
$query->groupBy('name'); public function getForEntity($entityType, $entityId)
{
$entity = $this->getEntity($entityType, $entityId);
if ($entity === null) {
return collect();
} }
if ($searchTerm) { return $entity->tags;
$query->where(function (Builder $query) use ($searchTerm) {
$query->where('name', 'like', '%' . $searchTerm . '%')
->orWhere('value', 'like', '%' . $searchTerm . '%');
});
}
return $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
} }
/** /**
* Get tag name suggestions from scanning existing tag names. * Get tag name suggestions from scanning existing tag names.
* If no search term is given the 50 most popular tag names are provided. * 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() $query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name');
->select('*', DB::raw('count(*) as count'))
->groupBy('name');
if ($searchTerm) { if ($searchTerm) {
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc'); $query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
@@ -71,7 +75,6 @@ class TagRepo
} }
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type'); $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['name'])->pluck('name'); return $query->get(['name'])->pluck('name');
} }
@@ -79,12 +82,13 @@ class TagRepo
* Get tag value suggestions from scanning existing tag values. * Get tag value suggestions from scanning existing tag values.
* If no search is given the 50 most popular values are provided. * 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. * 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() $query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value');
->select('*', DB::raw('count(*) as count'))
->groupBy('value');
if ($searchTerm) { if ($searchTerm) {
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc'); $query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
@@ -92,40 +96,45 @@ class TagRepo
$query = $query->orderBy('count', 'desc')->take(50); $query = $query->orderBy('count', 'desc')->take(50);
} }
if ($tagName) { if ($tagName !== false) {
$query = $query->where('name', '=', $tagName); $query = $query->where('name', '=', $tagName);
} }
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type'); $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['value'])->pluck('value'); 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(); $entity->tags()->delete();
$newTags = [];
$newTags = collect($tags)->filter(function ($tag) { foreach ($tags as $tag) {
return boolval(trim($tag['name'])); if (trim($tag['name']) === '') {
})->map(function ($tag) { continue;
return $this->newInstanceFromInput($tag); }
})->all(); $newTags[] = $this->newInstanceFromInput($tag);
}
return $entity->tags()->saveMany($newTags); return $entity->tags()->saveMany($newTags);
} }
/** /**
* Create a new Tag instance from user input. * 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']);
'name' => trim($input['name']), $value = isset($input['value']) ? trim($input['value']) : '';
'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,58 +1,18 @@
<?php <?php namespace BookStack\Actions;
namespace BookStack\Actions;
use BookStack\Interfaces\Viewable;
use BookStack\Model; use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* Class View
* Views are stored per-item per-person within the database.
* They can be used to find popular items or recently viewed items
* at a per-person level. They do not record every view instance as an
* activity. Only the latest and original view times could be recognised.
*
* @property int $views
* @property int $user_id
*/
class View extends Model class View extends Model
{ {
protected $fillable = ['user_id', 'views']; protected $fillable = ['user_id', 'views'];
/** /**
* Get all owning viewable models. * Get all owning viewable models.
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/ */
public function viewable(): MorphTo public function viewable()
{ {
return $this->morphTo(); return $this->morphTo();
} }
/**
* Increment the current user's view count for the given viewable model.
*/
public static function incrementFor(Viewable $viewable): int
{
$user = user();
if (is_null($user) || $user->isDefault()) {
return 0;
}
/** @var View $view */
$view = $viewable->views()->firstOrNew([
'user_id' => $user->id,
], ['views' => 0]);
$view->forceFill(['views' => $view->views + 1])->save();
return $view->views;
}
/**
* Clear all views from the system.
*/
public static function clearAll()
{
static::query()->truncate();
}
} }

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
{
protected $fillable = ['name', 'endpoint', 'timeout'];
use HasFactory;
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,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
{
protected $fillable = ['event'];
use HasFactory;
}

View File

@@ -1,158 +0,0 @@
<?php
namespace BookStack\Api;
use BookStack\Http\Controllers\Api\ApiController;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
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 = $class->getValdationRules()[$methodName] ?? [];
return empty($rules) ? null : $rules;
}
/**
* 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,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,141 +0,0 @@
<?php
namespace BookStack\Api;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
class ListingResponseBuilder
{
protected $query;
protected $request;
protected $fields;
protected $filterOperators = [
'eq' => '=',
'ne' => '!=',
'gt' => '>',
'lt' => '<',
'gte' => '>=',
'lte' => '<=',
'like' => 'like',
];
/**
* ListingResponseBuilder constructor.
*/
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()
{
$filteredQuery = $this->filterQuery($this->query);
$total = $filteredQuery->count();
$data = $this->fetchData($filteredQuery);
return response()->json([
'data' => $data,
'total' => $total,
]);
}
/**
* Fetch the data to return in 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 class Application extends \Illuminate\Foundation\Application
{ {
/** /**
* Get the path to the application configuration files. * 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 * @return string
*/ */
public function configPath($path = '') public function configPath($path = '')
{ {
return $this->basePath return $this->basePath.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Config'.($path ? DIRECTORY_SEPARATOR.$path : $path);
. DIRECTORY_SEPARATOR
. 'app'
. DIRECTORY_SEPARATOR
. 'Config'
. ($path ? DIRECTORY_SEPARATOR . $path : $path);
} }
}
}

View File

@@ -1,6 +1,4 @@
<?php <?php namespace BookStack\Auth\Access;
namespace BookStack\Auth\Access;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Exceptions\ConfirmationEmailException; use BookStack\Exceptions\ConfirmationEmailException;
@@ -14,7 +12,7 @@ class EmailConfirmationService extends UserTokenService
/** /**
* Create new confirmation for a user, * Create new confirmation for a user,
* Also removes any existing old ones. * Also removes any existing old ones.
* * @param User $user
* @throws ConfirmationEmailException * @throws ConfirmationEmailException
*/ */
public function sendConfirmation(User $user) public function sendConfirmation(User $user)
@@ -31,10 +29,12 @@ class EmailConfirmationService extends UserTokenService
/** /**
* Check if confirmation is required in this instance. * Check if confirmation is required in this instance.
* @return bool
*/ */
public function confirmationRequired(): bool public function confirmationRequired() : bool
{ {
return setting('registration-confirmation') return setting('registration-confirmation')
|| setting('registration-restrict'); || 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,76 +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
{
$externalAuthIds = explode(',', strtolower($externalId));
foreach ($externalAuthIds as $externalAuthId) {
if (in_array(trim($externalAuthId), $groupNames)) {
return true;
}
}
return false;
}
/**
* 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,133 +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\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;
/**
* 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 LoginAttemptException
* @throws LdapException
*
* @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->message);
}
}
// 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 <?php namespace BookStack\Auth\Access;
namespace BookStack\Auth\Access;
/** /**
* Class Ldap * Class Ldap
* An object-orientated thin abstraction wrapper for common PHP LDAP functions. * An object-orientated thin abstraction wrapper for common PHP LDAP functions.
* Allows the standard LDAP functions to be mocked for testing. * Allows the standard LDAP functions to be mocked for testing.
* @package BookStack\Services
*/ */
class Ldap class Ldap
{ {
/** /**
* Connect to an LDAP server. * Connect to a LDAP server.
* * @param string $hostName
* @param int $port
* @return resource * @return resource
*/ */
public function connect(string $hostName, int $port) public function connect($hostName, $port)
{ {
return ldap_connect($hostName, $port); return ldap_connect($hostName, $port);
} }
/** /**
* Set the value of a LDAP option for the given connection. * Set the value of a LDAP option for the given connection.
*
* @param resource $ldapConnection * @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); 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. * Set the version number for the given ldap connection.
* * @param $ldapConnection
* @param resource $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); return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
} }
/** /**
* Search LDAP tree using the provided filter. * Search LDAP tree using the provided filter.
*
* @param resource $ldapConnection * @param resource $ldapConnection
* @param string $baseDn * @param string $baseDn
* @param string $filter * @param string $filter
* @param array|null $attributes * @param array|null $attributes
*
* @return resource * @return resource
*/ */
public function search($ldapConnection, $baseDn, $filter, array $attributes = null) public function search($ldapConnection, $baseDn, $filter, array $attributes = null)
@@ -65,10 +58,8 @@ class Ldap
/** /**
* Get entries from an ldap search result. * Get entries from an ldap search result.
*
* @param resource $ldapConnection * @param resource $ldapConnection
* @param resource $ldapSearchResult * @param resource $ldapSearchResult
*
* @return array * @return array
*/ */
public function getEntries($ldapConnection, $ldapSearchResult) public function getEntries($ldapConnection, $ldapSearchResult)
@@ -78,28 +69,23 @@ class Ldap
/** /**
* Search and get entries immediately. * Search and get entries immediately.
*
* @param resource $ldapConnection * @param resource $ldapConnection
* @param string $baseDn * @param string $baseDn
* @param string $filter * @param string $filter
* @param array|null $attributes * @param array|null $attributes
*
* @return resource * @return resource
*/ */
public function searchAndGetEntries($ldapConnection, $baseDn, $filter, array $attributes = null) public function searchAndGetEntries($ldapConnection, $baseDn, $filter, array $attributes = null)
{ {
$search = $this->search($ldapConnection, $baseDn, $filter, $attributes); $search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
return $this->getEntries($ldapConnection, $search); return $this->getEntries($ldapConnection, $search);
} }
/** /**
* Bind to LDAP directory. * Bind to LDAP directory.
*
* @param resource $ldapConnection * @param resource $ldapConnection
* @param string $bindRdn * @param string $bindRdn
* @param string $bindPassword * @param string $bindPassword
*
* @return bool * @return bool
*/ */
public function bind($ldapConnection, $bindRdn = null, $bindPassword = null) public function bind($ldapConnection, $bindRdn = null, $bindPassword = null)
@@ -109,10 +95,8 @@ class Ldap
/** /**
* Explode a LDAP dn string into an array of components. * Explode a LDAP dn string into an array of components.
*
* @param string $dn * @param string $dn
* @param int $withAttrib * @param int $withAttrib
*
* @return array * @return array
*/ */
public function explodeDn(string $dn, int $withAttrib) public function explodeDn(string $dn, int $withAttrib)
@@ -122,14 +106,12 @@ class Ldap
/** /**
* Escape a string for use in an LDAP filter. * Escape a string for use in an LDAP filter.
*
* @param string $value * @param string $value
* @param string $ignore * @param string $ignore
* @param int $flags * @param int $flags
*
* @return string * @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); return ldap_escape($value, $ignore, $flags);
} }

View File

@@ -1,64 +1,61 @@
<?php <?php namespace BookStack\Auth\Access;
namespace BookStack\Auth\Access;
use BookStack\Auth\Access;
use BookStack\Auth\Role;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException; use BookStack\Auth\UserRepo;
use BookStack\Exceptions\LdapException; use BookStack\Exceptions\LdapException;
use BookStack\Uploads\UserAvatars; use Illuminate\Contracts\Auth\Authenticatable;
use ErrorException; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
/** /**
* Class LdapService * Class LdapService
* Handles any app-specific LDAP tasks. * Handles any app-specific LDAP tasks.
* @package BookStack\Services
*/ */
class LdapService class LdapService
{ {
protected $ldap; protected $ldap;
protected $groupSyncService;
protected $ldapConnection; protected $ldapConnection;
protected $userAvatars;
protected $config; protected $config;
protected $userRepo;
protected $enabled; protected $enabled;
/** /**
* LdapService constructor. * 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->ldap = $ldap;
$this->userAvatars = $userAvatars;
$this->groupSyncService = $groupSyncService;
$this->config = config('services.ldap'); $this->config = config('services.ldap');
$this->userRepo = $userRepo;
$this->enabled = config('auth.method') === 'ldap'; $this->enabled = config('auth.method') === 'ldap';
} }
/** /**
* Check if groups should be synced. * Check if groups should be synced.
* @return bool
*/ */
public function shouldSyncGroups(): bool public function shouldSyncGroups()
{ {
return $this->enabled && $this->config['user_to_groups'] !== false; 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 * @throws LdapException
*/ */
private function getUserWithAttributes(string $userName, array $attributes): ?array private function getUserWithAttributes($userName, $attributes)
{ {
$ldapConnection = $this->getConnection(); $ldapConnection = $this->getConnection();
$this->bindSystemUser($ldapConnection); $this->bindSystemUser($ldapConnection);
// Clean attributes
foreach ($attributes as $index => $attribute) {
if (strpos($attribute, 'BIN;') === 0) {
$attributes[$index] = substr($attribute, strlen('BIN;'));
}
}
// Find user // Find user
$userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]); $userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]);
$baseDn = $this->config['base_dn']; $baseDn = $this->config['base_dn'];
@@ -76,85 +73,69 @@ class LdapService
/** /**
* Get the details of a user from LDAP using the given username. * Get the details of a user from LDAP using the given username.
* User found via configurable user filter. * User found via configurable user filter.
* * @param $userName
* @return array|null
* @throws LdapException * @throws LdapException
*/ */
public function getUserDetails(string $userName): ?array public function getUserDetails($userName)
{ {
$idAttr = $this->config['id_attribute'];
$emailAttr = $this->config['email_attribute']; $emailAttr = $this->config['email_attribute'];
$displayNameAttr = $this->config['display_name_attribute']; $displayNameAttr = $this->config['display_name_attribute'];
$thumbnailAttr = $this->config['thumbnail_attribute'];
$user = $this->getUserWithAttributes($userName, array_filter([ $user = $this->getUserWithAttributes($userName, ['cn', 'uid', 'dn', $emailAttr, $displayNameAttr]);
'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr,
]));
if (is_null($user)) { if ($user === null) {
return null; return null;
} }
$userCn = $this->getUserResponseProperty($user, 'cn', null); $userCn = $this->getUserResponseProperty($user, 'cn', null);
$formatted = [ return [
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']), 'uid' => $this->getUserResponseProperty($user, 'uid', $user['dn']),
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn), 'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'dn' => $user['dn'], 'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null), '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. * Get a property from an LDAP user response fetch.
* Handles properties potentially being part of an array. * Handles properties potentially being part of an array.
* If the given key is prefixed with 'BIN;', that indicator will be stripped * @param array $userDetails
* from the key and any fetched values will be converted from binary to hex. * @param string $propertyKey
* @param $defaultValue
* @return mixed
*/ */
protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue) 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])) { if (isset($userDetails[$propertyKey])) {
$value = (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]); return (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
if ($isBinary) {
$value = bin2hex($value);
}
} }
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 * @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; return false;
} }
$ldapConnection = $this->getConnection(); $ldapConnection = $this->getConnection();
try { try {
$ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password); $ldapBind = $this->ldap->bind($ldapConnection, $ldapUser['dn'], $password);
} catch (ErrorException $e) { } catch (\ErrorException $e) {
$ldapBind = false; $ldapBind = false;
} }
@@ -164,9 +145,7 @@ class LdapService
/** /**
* Bind the system user to the LDAP connection using the given credentials * Bind the system user to the LDAP connection using the given credentials
* otherwise anonymous access is attempted. * otherwise anonymous access is attempted.
* * @param $connection
* @param resource $connection
*
* @throws LdapException * @throws LdapException
*/ */
protected function bindSystemUser($connection) protected function bindSystemUser($connection)
@@ -189,10 +168,8 @@ class LdapService
/** /**
* Get the connection to the LDAP server. * Get the connection to the LDAP server.
* Creates a new connection if one does not exist. * Creates a new connection if one does not exist.
*
* @throws LdapException
*
* @return resource * @return resource
* @throws LdapException
*/ */
protected function getConnection() protected function getConnection()
{ {
@@ -205,8 +182,8 @@ class LdapService
throw new LdapException(trans('errors.ldap_extension_not_installed')); throw new LdapException(trans('errors.ldap_extension_not_installed'));
} }
// Disable certificate verification. // Check if TLS_INSECURE is set. The handle is set to NULL due to the nature of
// This option works globally and must be set before a connection is created. // the LDAP_OPT_X_TLS_REQUIRE_CERT option. It can only be set globally and not per handle.
if ($this->config['tls_insecure']) { if ($this->config['tls_insecure']) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER); $this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
} }
@@ -223,24 +200,17 @@ class LdapService
$this->ldap->setVersion($ldapConnection, $this->config['version']); $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; $this->ldapConnection = $ldapConnection;
return $this->ldapConnection; return $this->ldapConnection;
} }
/** /**
* Parse a LDAP server string and return the host and port for a connection. * Parse a LDAP server string and return the host and port for
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'. * 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); $serverNameParts = explode(':', $serverString);
@@ -252,30 +222,32 @@ class LdapService
// Otherwise, extract the port out // Otherwise, extract the port out
$hostName = $serverNameParts[0]; $hostName = $serverNameParts[0];
$ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389; $ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
return ['host' => $hostName, 'port' => $ldapPort]; return ['host' => $hostName, 'port' => $ldapPort];
} }
/** /**
* Build a filter string by injecting common variables. * 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 = []; $newAttrs = [];
foreach ($attrs as $key => $attrText) { foreach ($attrs as $key => $attrText) {
$newKey = '${' . $key . '}'; $newKey = '${' . $key . '}';
$newAttrs[$newKey] = $this->ldap->escape($attrText); $newAttrs[$newKey] = $this->ldap->escape($attrText);
} }
return strtr($filterString, $newAttrs); 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 LdapException
*/ */
public function getUserGroups(string $userName): array public function getUserGroups($userName)
{ {
$groupsAttr = $this->config['group_attribute']; $groupsAttr = $this->config['group_attribute'];
$user = $this->getUserWithAttributes($userName, [$groupsAttr]); $user = $this->getUserWithAttributes($userName, [$groupsAttr]);
@@ -285,43 +257,45 @@ class LdapService
} }
$userGroups = $this->groupFilter($user); $userGroups = $this->groupFilter($user);
$userGroups = $this->getGroupsRecursive($userGroups, []);
return $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 * @throws LdapException
*/ */
private function getGroupsRecursive(array $groupsArray, array $checked): array private function getGroupsRecursive($groupsArray, $checked)
{ {
$groupsToAdd = []; $groups_to_add = [];
foreach ($groupsArray as $groupName) { foreach ($groupsArray as $groupName) {
if (in_array($groupName, $checked)) { if (in_array($groupName, $checked)) {
continue; continue;
} }
$parentGroups = $this->getGroupGroups($groupName); $groupsToAdd = $this->getGroupGroups($groupName);
$groupsToAdd = array_merge($groupsToAdd, $parentGroups); $groups_to_add = array_merge($groups_to_add, $groupsToAdd);
$checked[] = $groupName; $checked[] = $groupName;
} }
$groupsArray = array_unique(array_merge($groupsArray, $groups_to_add), SORT_REGULAR);
$groupsArray = array_unique(array_merge($groupsArray, $groupsToAdd), SORT_REGULAR); if (!empty($groups_to_add)) {
return $this->getGroupsRecursive($groupsArray, $checked);
if (empty($groupsToAdd)) { } else {
return $groupsArray; 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 * @throws LdapException
*/ */
private function getGroupGroups(string $groupName): array private function getGroupGroups($groupName)
{ {
$ldapConnection = $this->getConnection(); $ldapConnection = $this->getConnection();
$this->bindSystemUser($ldapConnection); $this->bindSystemUser($ldapConnection);
@@ -338,21 +312,24 @@ class LdapService
return []; 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. * Filter out LDAP CN and DN language in a ldap search return
* Gets the base CN (common name) of the string. * 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']); $groupsAttr = strtolower($this->config['group_attribute']);
$ldapGroups = []; $ldapGroups = [];
$count = 0; $count = 0;
if (isset($userGroupSearchResponse[$groupsAttr]['count'])) { if (isset($userGroupSearchResponse[$groupsAttr]['count'])) {
$count = (int) $userGroupSearchResponse[$groupsAttr]['count']; $count = (int)$userGroupSearchResponse[$groupsAttr]['count'];
} }
for ($i = 0; $i < $count; $i++) { for ($i = 0; $i < $count; $i++) {
@@ -366,31 +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 LdapException
*/ */
public function syncGroups(User $user, string $username) public function syncGroups(User $user, string $username)
{ {
$userLdapGroups = $this->getUserGroups($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 * Match an array of group names from LDAP to BookStack system roles.
* to the given user model. * 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'])) { foreach ($groupNames as $i => $groupName) {
return; $groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
} }
try { $roles = Role::query()->where(function (Builder $query) use ($groupNames) {
$imageData = $ldapUserDetails['avatar']; $query->whereIn('name', $groupNames);
$this->userAvatars->assignToUserFromExistingData($user, $imageData, 'jpg'); foreach ($groupNames as $groupName) {
} catch (\Exception $exception) { $query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
Log::info("Failed to use avatar image from LDAP data for user id {$user->id}"); }
})->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,164 +0,0 @@
<?php
namespace BookStack\Auth\Access;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\Mfa\MfaSession;
use BookStack\Auth\User;
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
*/
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,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,7 +0,0 @@
<?php
namespace BookStack\Auth\Access\Oidc;
class OidcIssuerDiscoveryException extends \Exception
{
}

View File

@@ -1,116 +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)
{
if ($jwk['alg'] !== 'RS256') {
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}");
}
if (empty($jwk['use'])) {
throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected');
}
if ($jwk['use'] !== 'sig') {
throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
}
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,127 +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;
/**
* 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 '';
}
/**
* 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 ['openid', 'profile', 'email'];
}
/**
* 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,203 +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
{
/**
* @var string
*/
public $issuer;
/**
* @var string
*/
public $clientId;
/**
* @var string
*/
public $clientSecret;
/**
* @var string
*/
public $redirectUri;
/**
* @var string
*/
public $authorizationEndpoint;
/**
* @var string
*/
public $tokenEndpoint;
/**
* @var string[]|array[]
*/
public $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) {
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && $key['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,221 +0,0 @@
<?php
namespace BookStack\Auth\Access\Oidc;
use function auth;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\OpenIdConnectException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use function config;
use Exception;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface as HttpClient;
use function trans;
use function url;
/**
* Class OpenIdConnectService
* Handles any app-specific OIDC tasks.
*/
class OidcService
{
protected $registrationService;
protected $loginService;
protected $httpClient;
/**
* OpenIdService constructor.
*/
public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
{
$this->registrationService = $registrationService;
$this->loginService = $loginService;
$this->httpClient = $httpClient;
}
/**
* Initiate an authorization flow.
*
* @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.
* Returns null if not authenticated.
*
* @throws Exception
* @throws ClientExceptionInterface
*/
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 OidcIssuerDiscoveryException
* @throws ClientExceptionInterface
*/
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) {
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
}
$settings->validate();
return $settings;
}
/**
* Load the underlying OpenID Connect Provider.
*/
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{
return new OidcOAuthProvider($settings->arrayForProvider(), [
'httpClient' => $this->httpClient,
'optionProvider' => new HttpBasicAuthOptionProvider(),
]);
}
/**
* 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 details of a user from an ID token.
*
* @return array{name: string, email: string, external_id: string}
*/
protected function getUserDetails(OidcIdToken $token): array
{
$id = $token->getClaim('sub');
return [
'external_id' => $id,
'email' => $token->getClaim('email'),
'name' => $this->getUserDisplayName($token, $id),
];
}
/**
* Processes a received access token for a user. Login the user when
* they exist, optionally registering them automatically.
*
* @throws OpenIdConnectException
* @throws JsonDebugException
* @throws UserRegistrationException
* @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 OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}");
}
$userDetails = $this->getUserDetails($idToken);
$isLoggedIn = auth()->check();
if (empty($userDetails['email'])) {
throw new OpenIdConnectException(trans('errors.oidc_no_email_address'));
}
if ($isLoggedIn) {
throw new OpenIdConnectException(trans('errors.oidc_already_logged_in'), '/login');
}
$user = $this->registrationService->findOrRegister(
$userDetails['name'],
$userDetails['email'],
$userDetails['external_id']
);
if ($user === null) {
throw new OpenIdConnectException(trans('errors.oidc_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
}
$this->loginService->login($user, 'oidc');
return $user;
}
/**
* Get the OIDC config from the application.
*/
protected function config(): array
{
return config('oidc');
}
}

View File

@@ -1,148 +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->registerNew($userData, $emailConfirmed);
// Assign social account if given
if ($socialAccount) {
$newUser->socialAccounts()->save($socialAccount);
}
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver(), $newUser);
// Start email confirmation flow if required
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
$newUser->save();
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,396 +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 $config;
protected $registrationService;
protected $loginService;
protected $groupSyncService;
/**
* Saml2Service constructor.
*/
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)) {
throw new Error(
'Invalid ACS Response: ' . implode(', ', $errors)
);
}
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();
$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(): Auth
{
$settings = $this->config['onelogin'];
$overrides = $this->config['onelogin_overrides'] ?? [];
if ($overrides && is_string($overrides)) {
$overrides = json_decode($overrides, true);
}
$metaDataSettings = [];
if ($this->config['autoload_from_metadata']) {
$metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']);
}
$spSettings = $this->loadOneloginServiceProviderDetails();
$settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
return new Auth($settings);
}
/**
* 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 <?php namespace BookStack\Auth\Access;
namespace BookStack\Auth\Access;
use BookStack\Auth\SocialAccount; use BookStack\Auth\SocialAccount;
use BookStack\Auth\User; use BookStack\Auth\UserRepo;
use BookStack\Exceptions\SocialDriverNotConfigured; use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed; use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite; use Laravel\Socialite\Contracts\Factory as Socialite;
use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Contracts\User as SocialUser; use Laravel\Socialite\Contracts\User as SocialUser;
use Laravel\Socialite\Two\GoogleProvider;
use SocialiteProviders\Manager\SocialiteWasCalled;
use Symfony\Component\HttpFoundation\RedirectResponse;
class SocialAuthService class SocialAuthService
{ {
/**
* The core socialite library used. protected $userRepo;
*
* @var Socialite
*/
protected $socialite; protected $socialite;
protected $socialAccount;
/** protected $validSocialDrivers = ['google', 'github', 'facebook', 'slack', 'twitter', 'azure', 'okta', 'gitlab', 'twitch', 'discord'];
* @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 = [];
/** /**
* SocialAuthService constructor. * 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->socialite = $socialite;
$this->loginService = $loginService; $this->socialAccount = $socialAccount;
} }
/** /**
* Start the social login path. * Start the social login path.
* * @param string $socialDriver
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* @throws SocialDriverNotConfigured * @throws SocialDriverNotConfigured
*/ */
public function startLogIn(string $socialDriver): RedirectResponse public function startLogIn($socialDriver)
{ {
$driver = $this->validateDriver($socialDriver); $driver = $this->validateDriver($socialDriver);
return $this->getSocialDriver($driver)->redirect();
return $this->getDriverForRedirect($driver)->redirect();
} }
/** /**
* Start the social registration process. * Start the social registration process
* * @param string $socialDriver
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* @throws SocialDriverNotConfigured * @throws SocialDriverNotConfigured
*/ */
public function startRegister(string $socialDriver): RedirectResponse public function startRegister($socialDriver)
{ {
$driver = $this->validateDriver($socialDriver); $driver = $this->validateDriver($socialDriver);
return $this->getSocialDriver($driver)->redirect();
return $this->getDriverForRedirect($driver)->redirect();
} }
/** /**
* Handle the social registration process on callback. * Handle the social registration process on callback.
* * @param string $socialDriver
* @param SocialUser $socialUser
* @return SocialUser
* @throws UserRegistrationException * @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 // Check social account has not already been used
if (SocialAccount::query()->where('driver_id', '=', $socialUser->getId())->exists()) { if ($this->socialAccount->where('driver_id', '=', $socialUser->getId())->exists()) {
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount' => $socialDriver]), '/login'); 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(); $email = $socialUser->getEmail();
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount'=>$socialDriver, 'email' => $email]), '/login');
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $email]), '/login');
} }
return $socialUser; return $socialUser;
@@ -114,87 +79,85 @@ class SocialAuthService
/** /**
* Get the social user details via the social driver. * Get the social user details via the social driver.
* * @param string $socialDriver
* @return SocialUser
* @throws SocialDriverNotConfigured * @throws SocialDriverNotConfigured
*/ */
public function getSocialUser(string $socialDriver): SocialUser public function getSocialUser(string $socialDriver)
{ {
$driver = $this->validateDriver($socialDriver); $driver = $this->validateDriver($socialDriver);
return $this->socialite->driver($driver)->user(); return $this->socialite->driver($driver)->user();
} }
/** /**
* Handle the login process on a oAuth callback. * Handle the login process on a oAuth callback.
* * @param $socialDriver
* @param SocialUser $socialUser
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws SocialSignInAccountNotUsed * @throws SocialSignInAccountNotUsed
*/ */
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser) public function handleLoginCallback($socialDriver, SocialUser $socialUser)
{ {
$socialId = $socialUser->getId(); $socialId = $socialUser->getId();
// Get any attached social accounts or users // 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(); $isLoggedIn = auth()->check();
$currentUser = user(); $currentUser = user();
$titleCaseDriver = Str::title($socialDriver);
// When a user is not logged in and a matching SocialAccount exists, // When a user is not logged in and a matching SocialAccount exists,
// Simply log the user into the application. // Simply log the user into the application.
if (!$isLoggedIn && $socialAccount !== null) { if (!$isLoggedIn && $socialAccount !== null) {
$this->loginService->login($socialAccount->user, $socialDriver); auth()->login($socialAccount->user);
return redirect()->intended('/'); return redirect()->intended('/');
} }
// When a user is logged in but the social account does not exist, // 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. // Create the social account and attach it to the user & redirect to the profile page.
if ($isLoggedIn && $socialAccount === null) { if ($isLoggedIn && $socialAccount === null) {
$account = $this->newSocialAccount($socialDriver, $socialUser); $this->fillSocialAccount($socialDriver, $socialUser);
$currentUser->socialAccounts()->save($account); $currentUser->socialAccounts()->save($this->socialAccount);
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver])); session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => title_case($socialDriver)]));
return redirect($currentUser->getEditUrl()); return redirect($currentUser->getEditUrl());
} }
// When a user is logged in and the social account exists and is already linked to the current user. // 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) { 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()); return redirect($currentUser->getEditUrl());
} }
// When a user is logged in, A social account exists but the users do not match. // 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) { 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()); return redirect($currentUser->getEditUrl());
} }
// Otherwise let the user know this social account is not used by anyone. // Otherwise let the user know this social account is not used by anyone.
$message = trans('errors.social_account_not_used', ['socialAccount' => $titleCaseDriver]); $message = trans('errors.social_account_not_used', ['socialAccount' => title_case($socialDriver)]);
if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') { if (setting('registration-enabled')) {
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]); $message .= trans('errors.social_account_register_instructions', ['socialAccount' => title_case($socialDriver)]);
} }
throw new SocialSignInAccountNotUsed($message, '/login'); throw new SocialSignInAccountNotUsed($message, '/login');
} }
/** /**
* Ensure the social driver is correct and supported. * Ensure the social driver is correct and supported.
* *
* @param $socialDriver
* @return string
* @throws SocialDriverNotConfigured * @throws SocialDriverNotConfigured
*/ */
protected function validateDriver(string $socialDriver): string private function validateDriver($socialDriver)
{ {
$driver = trim(strtolower($socialDriver)); $driver = trim(strtolower($socialDriver));
if (!in_array($driver, $this->validSocialDrivers)) { if (!in_array($driver, $this->validSocialDrivers)) {
abort(404, trans('errors.social_driver_not_found')); abort(404, trans('errors.social_driver_not_found'));
} }
if (!$this->checkDriverConfigured($driver)) { 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; return $driver;
@@ -202,114 +165,102 @@ class SocialAuthService
/** /**
* Check a social driver has been configured correctly. * 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); $lowerName = strtolower($driver);
$configPrefix = 'services.' . $lowerName . '.'; $configPrefix = 'services.' . $lowerName . '.';
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')]; $config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
return !in_array(false, $config) && !in_array(null, $config); return !in_array(false, $config) && !in_array(null, $config);
} }
/** /**
* Gets the names of the active social drivers. * Gets the names of the active social drivers.
* @return array
*/ */
public function getActiveDrivers(): array public function getActiveDrivers()
{ {
$activeDrivers = []; $activeDrivers = [];
foreach ($this->validSocialDrivers as $driverKey) { foreach ($this->validSocialDrivers as $driverKey) {
if ($this->checkDriverConfigured($driverKey)) { if ($this->checkDriverConfigured($driverKey)) {
$activeDrivers[$driverKey] = $this->getDriverName($driverKey); $activeDrivers[$driverKey] = $this->getDriverName($driverKey);
} }
} }
return $activeDrivers; return $activeDrivers;
} }
/** /**
* Get the presentational name for a driver. * 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'); return config('services.' . strtolower($driver) . '.name');
} }
/** /**
* Check if the current config for the given driver allows auto-registration. * 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; return config('services.' . strtolower($driver) . '.auto_register') === true;
} }
/** /**
* Check if the current config for the given driver allow email address auto-confirmation. * 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; 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' => $socialDriver,
'driver_id' => $socialUser->getId(), 'driver_id' => $socialUser->getId(),
'avatar' => $socialUser->getAvatar(), 'avatar' => $socialUser->getAvatar()
]); ]);
return $this->socialAccount;
} }
/** /**
* Detach a social account from a user. * 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(); 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); $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']); $driver->with(['prompt' => 'select_account']);
} }
if (isset($this->configureForRedirectCallbacks[$driverName])) {
$this->configureForRedirectCallbacks[$driverName]($driver);
}
return $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 <?php namespace BookStack\Auth\Access;
namespace BookStack\Auth\Access;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Notifications\UserInvite; use BookStack\Notifications\UserInvite;
@@ -13,7 +11,6 @@ class UserInviteService extends UserTokenService
/** /**
* Send an invitation to a user to sign into BookStack * Send an invitation to a user to sign into BookStack
* Removes existing invitation tokens. * Removes existing invitation tokens.
*
* @param User $user * @param User $user
*/ */
public function sendInvitation(User $user) public function sendInvitation(User $user)
@@ -22,4 +19,5 @@ class UserInviteService extends UserTokenService
$token = $this->createTokenForUser($user); $token = $this->createTokenForUser($user);
$user->notify(new UserInvite($token)); $user->notify(new UserInvite($token));
} }
} }

View File

@@ -1,56 +1,58 @@
<?php <?php namespace BookStack\Auth\Access;
namespace BookStack\Auth\Access;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Exceptions\UserTokenExpiredException; use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException; use BookStack\Exceptions\UserTokenNotFoundException;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Database\Connection as Database;
use Illuminate\Support\Str;
use stdClass; use stdClass;
class UserTokenService class UserTokenService
{ {
/** /**
* Name of table where user tokens are stored. * Name of table where user tokens are stored.
*
* @var string * @var string
*/ */
protected $tokenTable = 'user_tokens'; protected $tokenTable = 'user_tokens';
/** /**
* Token expiry time in hours. * Token expiry time in hours.
*
* @var int * @var int
*/ */
protected $expiryTime = 24; 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. * Delete all email confirmations that belong to a user.
*
* @param User $user * @param User $user
*
* @return mixed * @return mixed
*/ */
public function deleteByUser(User $user) public function deleteByUser(User $user)
{ {
return DB::table($this->tokenTable) return $this->db->table($this->tokenTable)
->where('user_id', '=', $user->id) ->where('user_id', '=', $user->id)
->delete(); ->delete();
} }
/** /**
* Get the user id from a token, while check the token exists and has not expired. * Get the user id from a token, while check the token exists and has not expired.
*
* @param string $token * @param string $token
* * @return int
* @throws UserTokenNotFoundException * @throws UserTokenNotFoundException
* @throws UserTokenExpiredException * @throws UserTokenExpiredException
*
* @return int
*/ */
public function checkTokenAndGetUserId(string $token): int public function checkTokenAndGetUserId(string $token) : int
{ {
$entry = $this->getEntryByToken($token); $entry = $this->getEntryByToken($token);
@@ -67,76 +69,66 @@ class UserTokenService
/** /**
* Creates a unique token within the email confirmation database. * Creates a unique token within the email confirmation database.
*
* @return string * @return string
*/ */
protected function generateToken(): string protected function generateToken() : string
{ {
$token = Str::random(24); $token = str_random(24);
while ($this->tokenExists($token)) { while ($this->tokenExists($token)) {
$token = Str::random(25); $token = str_random(25);
} }
return $token; return $token;
} }
/** /**
* Generate and store a token for the given user. * Generate and store a token for the given user.
*
* @param User $user * @param User $user
*
* @return string * @return string
*/ */
protected function createTokenForUser(User $user): string protected function createTokenForUser(User $user) : string
{ {
$token = $this->generateToken(); $token = $this->generateToken();
DB::table($this->tokenTable)->insert([ $this->db->table($this->tokenTable)->insert([
'user_id' => $user->id, 'user_id' => $user->id,
'token' => $token, 'token' => $token,
'created_at' => Carbon::now(), 'created_at' => Carbon::now(),
'updated_at' => Carbon::now(), 'updated_at' => Carbon::now()
]); ]);
return $token; return $token;
} }
/** /**
* Check if the given token exists. * Check if the given token exists.
*
* @param string $token * @param string $token
*
* @return bool * @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(); ->where('token', '=', $token)->exists();
} }
/** /**
* Get a token entry for the given token. * Get a token entry for the given token.
*
* @param string $token * @param string $token
*
* @return object|null * @return object|null
*/ */
protected function getEntryByToken(string $token) protected function getEntryByToken(string $token)
{ {
return DB::table($this->tokenTable) return $this->db->table($this->tokenTable)
->where('token', '=', $token) ->where('token', '=', $token)
->first(); ->first();
} }
/** /**
* Check if the given token entry has expired. * Check if the given token entry has expired.
*
* @param stdClass $tokenEntry * @param stdClass $tokenEntry
*
* @return bool * @return bool
*/ */
protected function entryExpired(stdClass $tokenEntry): bool protected function entryExpired(stdClass $tokenEntry) : bool
{ {
return Carbon::now()->subHours($this->expiryTime) return Carbon::now()->subHours($this->expiryTime)
->gt(new Carbon($tokenEntry->created_at)); ->gt(new Carbon($tokenEntry->created_at));
} }
}
}

View File

@@ -1,17 +1,15 @@
<?php <?php namespace BookStack\Auth\Permissions;
namespace BookStack\Auth\Permissions;
use BookStack\Model; use BookStack\Model;
class EntityPermission extends Model class EntityPermission extends Model
{ {
protected $fillable = ['role_id', 'action']; protected $fillable = ['role_id', 'action'];
public $timestamps = false; public $timestamps = false;
/** /**
* Get all this restriction's attached entity. * Get all this restriction's attached entity.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphTo * @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/ */
public function restrictable() public function restrictable()

View File

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

View File

@@ -1,35 +1,26 @@
<?php <?php namespace BookStack\Auth\Permissions;
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Permissions;
use BookStack\Auth\Role; use BookStack\Auth\Role;
use BookStack\Auth\User; use BookStack\Entities\Book;
use BookStack\Entities\Models\Book; use BookStack\Entities\Bookshelf;
use BookStack\Entities\Models\BookChild; use BookStack\Entities\Chapter;
use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Entity;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Page;
use BookStack\Entities\Models\Page; use BookStack\Ownable;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner;
use Illuminate\Database\Connection; use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Query\Builder as QueryBuilder;
use Throwable; use Illuminate\Support\Collection;
class PermissionService class PermissionService
{ {
/**
* @var ?array
*/
protected $userRoles = null;
/** protected $currentAction;
* @var ?User protected $isAdminUser;
*/ protected $userRoles = false;
protected $currentUserModel = null; protected $currentUserModel = false;
/** /**
* @var Connection * @var Connection
@@ -37,20 +28,52 @@ class PermissionService
protected $db; protected $db;
/** /**
* @var array * @var JointPermission
*/ */
protected $jointPermission;
/**
* @var Role
*/
protected $role;
/**
* @var EntityPermission
*/
protected $entityPermission;
/**
* @var EntityProvider
*/
protected $entityProvider;
protected $entityCache; protected $entityCache;
/** /**
* PermissionService constructor. * PermissionService constructor.
* @param JointPermission $jointPermission
* @param EntityPermission $entityPermission
* @param Role $role
* @param Connection $db
* @param EntityProvider $entityProvider
*/ */
public function __construct(Connection $db) public function __construct(
{ JointPermission $jointPermission,
Permissions\EntityPermission $entityPermission,
Role $role,
Connection $db,
EntityProvider $entityProvider
) {
$this->db = $db; $this->db = $db;
$this->jointPermission = $jointPermission;
$this->entityPermission = $entityPermission;
$this->role = $role;
$this->entityProvider = $entityProvider;
} }
/** /**
* Set the database connection. * Set the database connection
* @param Connection $connection
*/ */
public function setConnection(Connection $connection) public function setConnection(Connection $connection)
{ {
@@ -58,65 +81,82 @@ class PermissionService
} }
/** /**
* Prepare the local entity cache and ensure it's empty. * Prepare the local entity cache and ensure it's empty
* * @param \BookStack\Entities\Entity[] $entities
* @param Entity[] $entities
*/ */
protected function readyEntityCache(array $entities = []) protected function readyEntityCache($entities = [])
{ {
$this->entityCache = []; $this->entityCache = [];
foreach ($entities as $entity) { foreach ($entities as $entity) {
$class = get_class($entity); $type = $entity->getType();
if (!isset($this->entityCache[$class])) { if (!isset($this->entityCache[$type])) {
$this->entityCache[$class] = collect(); $this->entityCache[$type] = collect();
} }
$this->entityCache[$class]->put($entity->id, $entity); $this->entityCache[$type]->put($entity->id, $entity);
} }
} }
/** /**
* Get a book via ID, Checks local cache. * Get a book via ID, Checks local cache
* @param $bookId
* @return Book
*/ */
protected function getBook(int $bookId): ?Book protected function getBook($bookId)
{ {
if (isset($this->entityCache[Book::class]) && $this->entityCache[Book::class]->has($bookId)) { if (isset($this->entityCache['book']) && $this->entityCache['book']->has($bookId)) {
return $this->entityCache[Book::class]->get($bookId); return $this->entityCache['book']->get($bookId);
} }
return Book::query()->withTrashed()->find($bookId); $book = $this->entityProvider->book->find($bookId);
if ($book === null) {
$book = false;
}
return $book;
} }
/** /**
* Get a chapter via ID, Checks local cache. * Get a chapter via ID, Checks local cache
* @param $chapterId
* @return \BookStack\Entities\Book
*/ */
protected function getChapter(int $chapterId): ?Chapter protected function getChapter($chapterId)
{ {
if (isset($this->entityCache[Chapter::class]) && $this->entityCache[Chapter::class]->has($chapterId)) { if (isset($this->entityCache['chapter']) && $this->entityCache['chapter']->has($chapterId)) {
return $this->entityCache[Chapter::class]->get($chapterId); return $this->entityCache['chapter']->get($chapterId);
} }
return Chapter::query() $chapter = $this->entityProvider->chapter->find($chapterId);
->withTrashed() if ($chapter === null) {
->find($chapterId); $chapter = false;
}
return $chapter;
} }
/** /**
* Get the roles for the current logged in user. * Get the roles for the current user;
* @return array|bool
*/ */
protected function getCurrentUserRoles(): array protected function getRoles()
{ {
if (!is_null($this->userRoles)) { if ($this->userRoles !== false) {
return $this->userRoles; return $this->userRoles;
} }
$roles = [];
if (auth()->guest()) { if (auth()->guest()) {
$this->userRoles = [Role::getSystemRole('public')->id]; $roles[] = $this->role->getSystemRole('public')->id;
} else { return $roles;
$this->userRoles = $this->currentUser()->roles->pluck('id')->values()->all();
} }
return $this->userRoles;
foreach ($this->currentUser()->roles as $role) {
$roles[] = $role->id;
}
return $roles;
} }
/** /**
@@ -124,59 +164,60 @@ class PermissionService
*/ */
public function buildJointPermissions() public function buildJointPermissions()
{ {
JointPermission::query()->truncate(); $this->jointPermission->truncate();
$this->readyEntityCache(); $this->readyEntityCache();
// Get all roles (Should be the most limited dimension) // Get all roles (Should be the most limited dimension)
$roles = Role::query()->with('permissions')->get()->all(); $roles = $this->role->with('permissions')->get()->all();
// Chunk through all books // Chunk through all books
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) { $this->bookFetchQuery()->chunk(5, function ($books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles); $this->buildJointPermissionsForBooks($books, $roles);
}); });
// Chunk through all bookshelves // Chunk through all bookshelves
Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by']) $this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
->chunk(50, function (EloquentCollection $shelves) use ($roles) { ->chunk(50, function ($shelves) use ($roles) {
$this->buildJointPermissionsForShelves($shelves, $roles); $this->buildJointPermissionsForShelves($shelves, $roles);
}); });
} }
/** /**
* Get a query for fetching a book with it's children. * Get a query for fetching a book with it's children.
* @return QueryBuilder
*/ */
protected function bookFetchQuery(): Builder protected function bookFetchQuery()
{ {
return Book::query()->withTrashed() return $this->entityProvider->book->newQuery()
->select(['id', 'restricted', 'owned_by'])->with([ ->select(['id', 'restricted', 'created_by'])->with(['chapters' => function ($query) {
'chapters' => function ($query) { $query->select(['id', 'restricted', 'created_by', 'book_id']);
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']); }, 'pages' => function ($query) {
}, $query->select(['id', 'restricted', 'created_by', 'book_id', 'chapter_id']);
'pages' => function ($query) { }]);
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
},
]);
} }
/** /**
* Build joint permissions for the given shelf and role combinations. * @param Collection $shelves
* * @param array $roles
* @throws Throwable * @param bool $deleteOld
* @throws \Throwable
*/ */
protected function buildJointPermissionsForShelves(EloquentCollection $shelves, array $roles, bool $deleteOld = false) protected function buildJointPermissionsForShelves($shelves, $roles, $deleteOld = false)
{ {
if ($deleteOld) { if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($shelves->all()); $this->deleteManyJointPermissionsForEntities($shelves->all());
} }
$this->createManyJointPermissions($shelves->all(), $roles); $this->createManyJointPermissions($shelves, $roles);
} }
/** /**
* Build joint permissions for the given book and role combinations. * Build joint permissions for an array of books
* * @param Collection $books
* @throws Throwable * @param array $roles
* @param bool $deleteOld
* @throws \Throwable
*/ */
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false) protected function buildJointPermissionsForBooks($books, $roles, $deleteOld = false)
{ {
$entities = clone $books; $entities = clone $books;
@@ -193,56 +234,55 @@ class PermissionService
if ($deleteOld) { if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($entities->all()); $this->deleteManyJointPermissionsForEntities($entities->all());
} }
$this->createManyJointPermissions($entities->all(), $roles); $this->createManyJointPermissions($entities, $roles);
} }
/** /**
* Rebuild the entity jointPermissions for a particular entity. * Rebuild the entity jointPermissions for a particular entity.
* * @param \BookStack\Entities\Entity $entity
* @throws Throwable * @throws \Throwable
*/ */
public function buildJointPermissionsForEntity(Entity $entity) public function buildJointPermissionsForEntity(Entity $entity)
{ {
$entities = [$entity]; $entities = [$entity];
if ($entity instanceof Book) { if ($entity->isA('book')) {
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get(); $books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
$this->buildJointPermissionsForBooks($books, Role::query()->get()->all(), true); $this->buildJointPermissionsForBooks($books, $this->role->newQuery()->get(), true);
return; return;
} }
/** @var BookChild $entity */
if ($entity->book) { if ($entity->book) {
$entities[] = $entity->book; $entities[] = $entity->book;
} }
if ($entity instanceof Page && $entity->chapter_id) { if ($entity->isA('page') && $entity->chapter_id) {
$entities[] = $entity->chapter; $entities[] = $entity->chapter;
} }
if ($entity instanceof Chapter) { if ($entity->isA('chapter')) {
foreach ($entity->pages as $page) { foreach ($entity->pages as $page) {
$entities[] = $page; $entities[] = $page;
} }
} }
$this->buildJointPermissionsForEntities($entities); $this->buildJointPermissionsForEntities(collect($entities));
} }
/** /**
* Rebuild the entity jointPermissions for a collection of entities. * Rebuild the entity jointPermissions for a collection of entities.
* * @param Collection $entities
* @throws Throwable * @throws \Throwable
*/ */
public function buildJointPermissionsForEntities(array $entities) public function buildJointPermissionsForEntities(Collection $entities)
{ {
$roles = Role::query()->get()->values()->all(); $roles = $this->role->newQuery()->get();
$this->deleteManyJointPermissionsForEntities($entities); $this->deleteManyJointPermissionsForEntities($entities->all());
$this->createManyJointPermissions($entities, $roles); $this->createManyJointPermissions($entities, $roles);
} }
/** /**
* Build the entity jointPermissions for a particular role. * Build the entity jointPermissions for a particular role.
* @param Role $role
*/ */
public function buildJointPermissionForRole(Role $role) public function buildJointPermissionForRole(Role $role)
{ {
@@ -255,7 +295,7 @@ class PermissionService
}); });
// Chunk through all bookshelves // Chunk through all bookshelves
Bookshelf::query()->select(['id', 'restricted', 'owned_by']) $this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
->chunk(50, function ($shelves) use ($roles) { ->chunk(50, function ($shelves) use ($roles) {
$this->buildJointPermissionsForShelves($shelves, $roles); $this->buildJointPermissionsForShelves($shelves, $roles);
}); });
@@ -263,6 +303,7 @@ class PermissionService
/** /**
* Delete the entity jointPermissions attached to a particular role. * Delete the entity jointPermissions attached to a particular role.
* @param Role $role
*/ */
public function deleteJointPermissionsForRole(Role $role) public function deleteJointPermissionsForRole(Role $role)
{ {
@@ -271,7 +312,6 @@ class PermissionService
/** /**
* Delete all of the entity jointPermissions for a list of entities. * Delete all of the entity jointPermissions for a list of entities.
*
* @param Role[] $roles * @param Role[] $roles
*/ */
protected function deleteManyJointPermissionsForRoles($roles) protected function deleteManyJointPermissionsForRoles($roles)
@@ -279,15 +319,13 @@ class PermissionService
$roleIds = array_map(function ($role) { $roleIds = array_map(function ($role) {
return $role->id; return $role->id;
}, $roles); }, $roles);
JointPermission::query()->whereIn('role_id', $roleIds)->delete(); $this->jointPermission->newQuery()->whereIn('role_id', $roleIds)->delete();
} }
/** /**
* Delete the entity jointPermissions for a particular entity. * Delete the entity jointPermissions for a particular entity.
*
* @param Entity $entity * @param Entity $entity
* * @throws \Throwable
* @throws Throwable
*/ */
public function deleteJointPermissionsForEntity(Entity $entity) public function deleteJointPermissionsForEntity(Entity $entity)
{ {
@@ -296,18 +334,17 @@ class PermissionService
/** /**
* Delete all of the entity jointPermissions for a list of entities. * Delete all of the entity jointPermissions for a list of entities.
* * @param \BookStack\Entities\Entity[] $entities
* @param Entity[] $entities * @throws \Throwable
*
* @throws Throwable
*/ */
protected function deleteManyJointPermissionsForEntities(array $entities) protected function deleteManyJointPermissionsForEntities($entities)
{ {
if (count($entities) === 0) { if (count($entities) === 0) {
return; return;
} }
$this->db->transaction(function () use ($entities) { $this->db->transaction(function () use ($entities) {
foreach (array_chunk($entities, 1000) as $entityChunk) { foreach (array_chunk($entities, 1000) as $entityChunk) {
$query = $this->db->table('joint_permissions'); $query = $this->db->table('joint_permissions');
foreach ($entityChunk as $entity) { foreach ($entityChunk as $entity) {
@@ -322,21 +359,19 @@ class PermissionService
} }
/** /**
* Create & Save entity jointPermissions for many entities and roles. * Create & Save entity jointPermissions for many entities and jointPermissions.
* * @param Collection $entities
* @param Entity[] $entities * @param array $roles
* @param Role[] $roles * @throws \Throwable
*
* @throws Throwable
*/ */
protected function createManyJointPermissions(array $entities, array $roles) protected function createManyJointPermissions($entities, $roles)
{ {
$this->readyEntityCache($entities); $this->readyEntityCache($entities);
$jointPermissions = []; $jointPermissions = [];
// Fetch Entity Permissions and create a mapping of entity restricted statuses // Fetch Entity Permissions and create a mapping of entity restricted statuses
$entityRestrictedMap = []; $entityRestrictedMap = [];
$permissionFetch = EntityPermission::query(); $permissionFetch = $this->entityPermission->newQuery();
foreach ($entities as $entity) { foreach ($entities as $entity) {
$entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted')); $entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted'));
$permissionFetch->orWhere(function ($query) use ($entity) { $permissionFetch->orWhere(function ($query) use ($entity) {
@@ -377,27 +412,35 @@ class PermissionService
}); });
} }
/** /**
* Get the actions related to an entity. * Get the actions related to an entity.
* @param \BookStack\Entities\Entity $entity
* @return array
*/ */
protected function getActions(Entity $entity): array protected function getActions(Entity $entity)
{ {
$baseActions = ['view', 'update', 'delete']; $baseActions = ['view', 'update', 'delete'];
if ($entity instanceof Chapter || $entity instanceof Book) { if ($entity->isA('chapter') || $entity->isA('book')) {
$baseActions[] = 'page-create'; $baseActions[] = 'page-create';
} }
if ($entity instanceof Book) { if ($entity->isA('book')) {
$baseActions[] = 'chapter-create'; $baseActions[] = 'chapter-create';
} }
return $baseActions; return $baseActions;
} }
/** /**
* Create entity permission data for an entity and role * Create entity permission data for an entity and role
* for a particular action. * 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, string $action, array $permissionMap, array $rolePermissionMap): array protected function createJointPermissionData(Entity $entity, Role $role, $action, $permissionMap, $rolePermissionMap)
{ {
$permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action; $permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action;
$roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']); $roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']);
@@ -411,11 +454,10 @@ class PermissionService
if ($entity->restricted) { if ($entity->restricted) {
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $role, $restrictionAction); $hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $role, $restrictionAction);
return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess); return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
} }
if ($entity instanceof Book || $entity instanceof Bookshelf) { if ($entity->isA('book') || $entity->isA('bookshelf')) {
return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn); return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn);
} }
@@ -425,7 +467,7 @@ class PermissionService
$hasPermissiveAccessToParents = !$book->restricted; $hasPermissiveAccessToParents = !$book->restricted;
// For pages with a chapter, Check if explicit permissions are set on the Chapter // For pages with a chapter, Check if explicit permissions are set on the Chapter
if ($entity instanceof Page && intval($entity->chapter_id) !== 0) { if ($entity->isA('page') && $entity->chapter_id !== 0 && $entity->chapter_id !== '0') {
$chapter = $this->getChapter($entity->chapter_id); $chapter = $this->getChapter($entity->chapter_id);
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted; $hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
if ($chapter->restricted) { if ($chapter->restricted) {
@@ -444,19 +486,29 @@ class PermissionService
/** /**
* Check for an active restriction in an entity map. * Check for an active restriction in an entity map.
* @param $entityMap
* @param Entity $entity
* @param Role $role
* @param $action
* @return bool
*/ */
protected function mapHasActiveRestriction(array $entityMap, Entity $entity, Role $role, string $action): bool protected function mapHasActiveRestriction($entityMap, Entity $entity, Role $role, $action)
{ {
$key = $entity->getMorphClass() . ':' . $entity->getRawAttribute('id') . ':' . $role->getRawAttribute('id') . ':' . $action; $key = $entity->getMorphClass() . ':' . $entity->getRawAttribute('id') . ':' . $role->getRawAttribute('id') . ':' . $action;
return isset($entityMap[$key]) ? $entityMap[$key] : false;
return $entityMap[$key] ?? false;
} }
/** /**
* Create an array of data with the information of an entity jointPermissions. * Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion. * 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, string $action, bool $permissionAll, bool $permissionOwn): array protected function createJointPermissionDataArray(Entity $entity, Role $role, $action, $permissionAll, $permissionOwn)
{ {
return [ return [
'role_id' => $role->getRawAttribute('id'), 'role_id' => $role->getRawAttribute('id'),
@@ -465,243 +517,266 @@ class PermissionService
'action' => $action, 'action' => $action,
'has_permission' => $permissionAll, 'has_permission' => $permissionAll,
'has_permission_own' => $permissionOwn, 'has_permission_own' => $permissionOwn,
'owned_by' => $entity->getRawAttribute('owned_by'), 'created_by' => $entity->getRawAttribute('created_by')
]; ];
} }
/** /**
* Checks if an entity has a restriction set upon it. * Checks if an entity has a restriction set upon it.
* * @param Ownable $ownable
* @param HasCreatorAndUpdater|HasOwner $ownable * @param $permission
* @return bool
*/ */
public function checkOwnableUserAccess(Model $ownable, string $permission): bool public function checkOwnableUserAccess(Ownable $ownable, $permission)
{ {
$explodedPermission = explode('-', $permission); $explodedPermission = explode('-', $permission);
$baseQuery = $ownable->newQuery()->where('id', '=', $ownable->id); $baseQuery = $ownable->where('id', '=', $ownable->id);
$action = end($explodedPermission); $action = end($explodedPermission);
$user = $this->currentUser(); $this->currentAction = $action;
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment']; $nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
// Handle non entity specific jointPermissions // Handle non entity specific jointPermissions
if (in_array($explodedPermission[0], $nonJointPermissions)) { if (in_array($explodedPermission[0], $nonJointPermissions)) {
$allPermission = $user && $user->can($permission . '-all'); $allPermission = $this->currentUser() && $this->currentUser()->can($permission . '-all');
$ownPermission = $user && $user->can($permission . '-own'); $ownPermission = $this->currentUser() && $this->currentUser()->can($permission . '-own');
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by'; $this->currentAction = 'view';
$isOwner = $user && $user->id === $ownable->$ownerField; $isOwner = $this->currentUser() && $this->currentUser()->id === $ownable->created_by;
return ($allPermission || ($isOwner && $ownPermission));
return $allPermission || ($isOwner && $ownPermission);
} }
// Handle abnormal create jointPermissions // Handle abnormal create jointPermissions
if ($action === 'create') { if ($action === 'create') {
$action = $permission; $this->currentAction = $permission;
} }
$hasAccess = $this->entityRestrictionQuery($baseQuery, $action)->count() > 0; $q = $this->entityRestrictionQuery($baseQuery)->count() > 0;
$this->clean(); $this->clean();
return $q;
return $hasAccess;
} }
/** /**
* Checks if a user has the given permission for any items in the system. * 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. * 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): bool public function checkUserHasPermissionOnAnything(string $permission, string $entityClass = null)
{ {
$userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray(); $userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray();
$userId = $this->currentUser()->id; $userId = $this->currentUser()->id;
$permissionQuery = JointPermission::query() $permissionQuery = $this->db->table('joint_permissions')
->where('action', '=', $permission) ->where('action', '=', $permission)
->whereIn('role_id', $userRoleIds) ->whereIn('role_id', $userRoleIds)
->where(function (Builder $query) use ($userId) { ->where(function ($query) use ($userId) {
$this->addJointHasPermissionCheck($query, $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)) { if (!is_null($entityClass)) {
$entityInstance = app($entityClass); $entityInstance = app()->make($entityClass);
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass()); $permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
} }
$hasPermission = $permissionQuery->count() > 0; $hasPermission = $permissionQuery->count() > 0;
$this->clean(); $this->clean();
return $hasPermission; 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 * The general query filter to remove all entities
* that the current user does not have access to. * that the current user does not have access to.
* @param $query
* @return mixed
*/ */
protected function entityRestrictionQuery(Builder $query, string $action): Builder protected function entityRestrictionQuery($query)
{ {
$q = $query->where(function ($parentQuery) use ($action) { $q = $query->where(function ($parentQuery) {
$parentQuery->whereHas('jointPermissions', function ($permissionQuery) use ($action) { $parentQuery->whereHas('jointPermissions', function ($permissionQuery) {
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoles()) $permissionQuery->whereIn('role_id', $this->getRoles())
->where('action', '=', $action) ->where('action', '=', $this->currentAction)
->where(function (Builder $query) { ->where(function ($query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id); $query->where('has_permission', '=', true)
->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('created_by', '=', $this->currentUser()->id);
});
}); });
}); });
}); });
$this->clean(); $this->clean();
return $q; return $q;
} }
/** /**
* Limited the given entity query so that the query will only * Get the children of a book in an efficient single query, Filtered by the permission system.
* return items that the user has permission for the given ability. * @param integer $book_id
* @param bool $filterDrafts
* @param bool $fetchPageContent
* @return QueryBuilder
*/ */
public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder public function bookChildrenQuery($book_id, $filterDrafts = false, $fetchPageContent = false)
{ {
$this->clean(); $entities = $this->entityProvider;
$pageSelect = $this->db->table('pages')->selectRaw($entities->page->entityRawQuery($fetchPageContent))
return $query->where(function (Builder $parentQuery) use ($ability) { ->where('book_id', '=', $book_id)->where(function ($query) use ($filterDrafts) {
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) { $query->where('draft', '=', 0);
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoles()) if (!$filterDrafts) {
->where('action', '=', $ability) $query->orWhere(function ($query) {
->where(function (Builder $query) { $query->where('draft', '=', 1)->where('created_by', '=', $this->currentUser()->id);
$this->addJointHasPermissionCheck($query, $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
* Extend the given page query to ensure draft items are not visible $whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)')
* unless created by the given user. ->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type')
*/ ->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles())
public function enforceDraftVisibilityOnQuery(Builder $query): Builder ->where(function ($query) {
{ $query->where('jp.has_permission', '=', 1)->orWhere(function ($query) {
return $query->where(function (Builder $query) { $query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id);
$query->where('draft', '=', false)
->orWhere(function (Builder $query) {
$query->where('draft', '=', true)
->where('owned_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. * Add restrictions for a generic entity
* @param string $entityType
* @param Builder|\BookStack\Entities\Entity $query
* @param string $action
* @return Builder
*/ */
public function enforceEntityRestrictions(Entity $entity, Builder $query, string $action = 'view'): Builder public function enforceEntityRestrictions($entityType, $query, $action = 'view')
{ {
if ($entity instanceof Page) { if (strtolower($entityType) === 'page') {
// Prevent drafts being visible to others. // Prevent drafts being visible to others.
$this->enforceDraftVisibilityOnQuery($query); $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);
});
}
});
} }
return $this->entityRestrictionQuery($query, $action); $this->currentAction = $action;
return $this->entityRestrictionQuery($query);
} }
/** /**
* Filter items that have entities set as a polymorphic relation. * Filter items that have entities set as a polymorphic relation.
* For simplicity, this will not return results attached to draft pages. * @param $query
* Draft pages should never really have related items though. * @param string $tableName
* * @param string $entityIdColumn
* @param Builder|QueryBuilder $query * @param string $entityTypeColumn
* @param string $action
* @return QueryBuilder
*/ */
public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view') public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn, $action = 'view')
{ {
$this->currentAction = $action;
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn]; $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$pageMorphClass = (new Page())->getMorphClass();
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) { $q = $query->where(function ($query) use ($tableDetails) {
/** @var Builder $permissionQuery */ $query->whereExists(function ($permissionQuery) use (&$tableDetails) {
$permissionQuery->select(['role_id'])->from('joint_permissions') $permissionQuery->select('id')->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn']) ->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->where('joint_permissions.action', '=', $action) ->where('action', '=', $this->currentAction)
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles()) ->whereIn('role_id', $this->getRoles())
->where(function (QueryBuilder $query) { ->where(function ($query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id); $query->where('has_permission', '=', true)->orWhere(function ($query) {
}); $query->where('has_permission_own', '=', true)
})->where(function ($query) use ($tableDetails, $pageMorphClass) { ->where('created_by', '=', $this->currentUser()->id);
/** @var Builder $query */ });
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass) });
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) { });
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
->where('pages.draft', '=', false);
});
}); });
$this->clean(); $this->clean();
return $q; return $q;
} }
/** /**
* Add conditions to a query to filter the selection to related entities * Add conditions to a query to filter the selection to related entities
* where view permissions are granted. * where permissions are granted.
* @param $entityType
* @param $query
* @param $tableName
* @param $entityIdColumn
* @return mixed
*/ */
public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder public function filterRelatedEntity($entityType, $query, $tableName, $entityIdColumn)
{ {
$fullEntityIdColumn = $tableName . '.' . $entityIdColumn; $this->currentAction = 'view';
$instance = new $entityClass(); $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
$morphClass = $instance->getMorphClass();
$existsQuery = function ($permissionQuery) use ($fullEntityIdColumn, $morphClass) { $pageMorphClass = $this->entityProvider->get($entityType)->getMorphClass();
/** @var Builder $permissionQuery */
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions') $q = $query->where(function ($query) use ($tableDetails, $pageMorphClass) {
->whereColumn('joint_permissions.entity_id', '=', $fullEntityIdColumn) $query->where(function ($query) use (&$tableDetails, $pageMorphClass) {
->where('joint_permissions.entity_type', '=', $morphClass) $query->whereExists(function ($permissionQuery) use (&$tableDetails, $pageMorphClass) {
->where('joint_permissions.action', '=', 'view') $permissionQuery->select('id')->from('joint_permissions')
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles()) ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where(function (QueryBuilder $query) { ->where('entity_type', '=', $pageMorphClass)
$this->addJointHasPermissionCheck($query, $this->currentUser()->id); ->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);
$q = $query->where(function ($query) use ($existsQuery, $fullEntityIdColumn) {
$query->whereExists($existsQuery)
->orWhere($fullEntityIdColumn, '=', 0);
}); });
if ($instance instanceof Page) {
// Prevent visibility of non-owned draft pages
$q->whereExists(function (QueryBuilder $query) use ($fullEntityIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullEntityIdColumn)
->where(function (QueryBuilder $query) {
$query->where('pages.draft', '=', false)
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
});
});
}
$this->clean(); $this->clean();
return $q; return $q;
} }
/** /**
* Add the query for checking the given user id has permission * Get the current user
* within the join_permissions table. * @return \BookStack\Auth\User
*
* @param QueryBuilder|Builder $query
*/ */
protected function addJointHasPermissionCheck($query, int $userIdToCheck) private function currentUser()
{ {
$query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) { if ($this->currentUserModel === false) {
$query->where('joint_permissions.has_permission_own', '=', true)
->where('joint_permissions.owned_by', '=', $userIdToCheck);
});
}
/**
* Get the current user.
*/
private function currentUser(): User
{
if (is_null($this->currentUserModel)) {
$this->currentUserModel = user(); $this->currentUserModel = user();
} }
@@ -711,9 +786,10 @@ class PermissionService
/** /**
* Clean the cached user elements. * Clean the cached user elements.
*/ */
private function clean(): void private function clean()
{ {
$this->currentUserModel = null; $this->currentUserModel = false;
$this->userRoles = null; $this->userRoles = false;
$this->isAdminUser = null;
} }
} }

View File

@@ -1,16 +1,12 @@
<?php <?php namespace BookStack\Auth\Permissions;
namespace BookStack\Auth\Permissions; use BookStack\Auth\Permissions;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Role; use BookStack\Auth\Role;
use BookStack\Exceptions\PermissionsException; use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
use Exception;
use Illuminate\Database\Eloquent\Collection;
class PermissionsRepo class PermissionsRepo
{ {
protected $permission; protected $permission;
protected $role; protected $role;
protected $permissionService; protected $permissionService;
@@ -19,8 +15,11 @@ class PermissionsRepo
/** /**
* PermissionsRepo constructor. * PermissionsRepo constructor.
* @param RolePermission $permission
* @param Role $role
* @param \BookStack\Auth\Permissions\PermissionService $permissionService
*/ */
public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService) public function __construct(RolePermission $permission, Role $role, Permissions\PermissionService $permissionService)
{ {
$this->permission = $permission; $this->permission = $permission;
$this->role = $role; $this->role = $role;
@@ -29,53 +28,64 @@ class PermissionsRepo
/** /**
* Get all the user roles from the system. * Get all the user roles from the system.
* @return \Illuminate\Database\Eloquent\Collection|static[]
*/ */
public function getAllRoles(): Collection public function getAllRoles()
{ {
return $this->role->all(); return $this->role->all();
} }
/** /**
* Get all the roles except for the provided one. * 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 $this->role->where('id', '!=', $role->id)->get(); return $this->role->where('id', '!=', $role->id)->get();
} }
/** /**
* Get a role via its ID. * Get a role via its ID.
* @param $id
* @return mixed
*/ */
public function getRoleById($id): Role public function getRoleById($id)
{ {
return $this->role->newQuery()->findOrFail($id); return $this->role->findOrFail($id);
} }
/** /**
* Save a new role into the system. * Save a new role into the system.
* @param array $roleData
* @return Role
*/ */
public function saveNewRole(array $roleData): Role public function saveNewRole($roleData)
{ {
$role = $this->role->newInstance($roleData); $role = $this->role->newInstance($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true'; $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(); $role->save();
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : []; $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$this->assignRolePermissions($role, $permissions); $this->assignRolePermissions($role, $permissions);
$this->permissionService->buildJointPermissionForRole($role); $this->permissionService->buildJointPermissionForRole($role);
Activity::add(ActivityType::ROLE_CREATE, $role);
return $role; return $role;
} }
/** /**
* Updates an existing role. * Updates an existing role.
* Ensure Admin role always have core permissions. * Ensure Admin role always have core permissions.
* @param $roleId
* @param $roleData
* @throws PermissionsException
*/ */
public function updateRole($roleId, array $roleData) public function updateRole($roleId, $roleData)
{ {
/** @var Role $role */ $role = $this->role->findOrFail($roleId);
$role = $this->role->newQuery()->findOrFail($roleId);
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : []; $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
if ($role->system_name === 'admin') { if ($role->system_name === 'admin') {
@@ -91,27 +101,22 @@ class PermissionsRepo
$this->assignRolePermissions($role, $permissions); $this->assignRolePermissions($role, $permissions);
$role->fill($roleData); $role->fill($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->save(); $role->save();
$this->permissionService->buildJointPermissionForRole($role); $this->permissionService->buildJointPermissionForRole($role);
Activity::add(ActivityType::ROLE_UPDATE, $role);
} }
/** /**
* Assign an list of permission names to an 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 = []; $permissions = [];
$permissionNameArray = array_values($permissionNameArray); $permissionNameArray = array_values($permissionNameArray);
if ($permissionNameArray && count($permissionNameArray) > 0) {
if ($permissionNameArray) { $permissions = $this->permission->whereIn('name', $permissionNameArray)->pluck('id')->toArray();
$permissions = $this->permission->newQuery()
->whereIn('name', $permissionNameArray)
->pluck('id')
->toArray();
} }
$role->permissions()->sync($permissions); $role->permissions()->sync($permissions);
} }
@@ -120,32 +125,30 @@ class PermissionsRepo
* Check it's not an admin role or set as default before deleting. * 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 * If an migration Role ID is specified the users assign to the current role
* will be added to the role of the specified id. * will be added to the role of the specified id.
* * @param $roleId
* @param $migrateRoleId
* @throws PermissionsException * @throws PermissionsException
* @throws Exception
*/ */
public function deleteRole($roleId, $migrateRoleId) public function deleteRole($roleId, $migrateRoleId)
{ {
/** @var Role $role */ $role = $this->role->findOrFail($roleId);
$role = $this->role->newQuery()->findOrFail($roleId);
// Prevent deleting admin role or default registration role. // Prevent deleting admin role or default registration role.
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) { if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
throw new PermissionsException(trans('errors.role_system_cannot_be_deleted')); 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')); throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
} }
if ($migrateRoleId) { if ($migrateRoleId) {
$newRole = $this->role->newQuery()->find($migrateRoleId); $newRole = $this->role->find($migrateRoleId);
if ($newRole) { if ($newRole) {
$users = $role->users()->pluck('id')->toArray(); $users = $role->users->pluck('id')->toArray();
$newRole->users()->sync($users); $newRole->users()->sync($users);
} }
} }
$this->permissionService->deleteJointPermissionsForRole($role); $this->permissionService->deleteJointPermissionsForRole($role);
Activity::add(ActivityType::ROLE_DELETE, $role);
$role->delete(); $role->delete();
} }
} }

View File

@@ -1,28 +1,24 @@
<?php <?php namespace BookStack\Auth\Permissions;
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role; use BookStack\Auth\Role;
use BookStack\Model; use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
/**
* @property int $id
*/
class RolePermission extends Model class RolePermission extends Model
{ {
/** /**
* The roles that belong to the permission. * 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'); return $this->belongsToMany(Role::class, 'permission_role', 'permission_id', 'role_id');
} }
/** /**
* Get the permission object by name. * 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(); return static::where('name', '=', $name)->first();
} }

View File

@@ -1,45 +1,27 @@
<?php <?php namespace BookStack\Auth;
namespace BookStack\Auth;
use BookStack\Auth\Permissions\JointPermission; use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\RolePermission; use BookStack\Auth\Permissions\RolePermission;
use BookStack\Interfaces\Loggable;
use BookStack\Model; 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 extends Model
* 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
{ {
use HasFactory;
protected $fillable = ['display_name', 'description', 'external_auth_id']; protected $fillable = ['display_name', 'description', 'external_auth_id'];
/** /**
* The roles that belong to the role. * The roles that belong to the role.
*/ */
public function users(): BelongsToMany public function users()
{ {
return $this->belongsToMany(User::class)->orderBy('name', 'asc'); return $this->belongsToMany(User::class)->orderBy('name', 'asc');
} }
/** /**
* Get all related JointPermissions. * Get all related JointPermissions.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/ */
public function jointPermissions(): HasMany public function jointPermissions()
{ {
return $this->hasMany(JointPermission::class); return $this->hasMany(JointPermission::class);
} }
@@ -47,15 +29,17 @@ class Role extends Model implements Loggable
/** /**
* The RolePermissions that belong to the role. * 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'); return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
} }
/** /**
* Check if this role has a permission. * 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'); $permissions = $this->getRelationValue('permissions');
foreach ($permissions as $permission) { foreach ($permissions as $permission) {
@@ -63,12 +47,12 @@ class Role extends Model implements Loggable
return true; return true;
} }
} }
return false; return false;
} }
/** /**
* Add a permission to this role. * Add a permission to this role.
* @param RolePermission $permission
*/ */
public function attachPermission(RolePermission $permission) public function attachPermission(RolePermission $permission)
{ {
@@ -77,52 +61,39 @@ class Role extends Model implements Loggable
/** /**
* Detach a single permission from this role. * Detach a single permission from this role.
* @param RolePermission $permission
*/ */
public function detachPermission(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. * 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();
} }
/** /**
* Get all visible roles. * Get all visible roles
* @return mixed
*/ */
public static function visible(): Collection public static function visible()
{ {
return static::query()->where('hidden', '=', false)->orderBy('name')->get(); return static::where('hidden', '=', false)->orderBy('name')->get();
}
/**
* Get the roles that can be restricted.
*/
public static function restrictable(): Collection
{
return static::query()
->where('system_name', '!=', 'admin')
->orderBy('display_name', 'asc')
->get();
}
/**
* {@inheritdoc}
*/
public function logDescriptor(): string
{
return "({$this->id}) {$this->display_name}";
} }
} }

View File

@@ -1,30 +1,14 @@
<?php <?php namespace BookStack\Auth;
namespace BookStack\Auth;
use BookStack\Interfaces\Loggable;
use BookStack\Model; use BookStack\Model;
/** class SocialAccount extends Model
* Class SocialAccount.
*
* @property string $driver
* @property User $user
*/
class SocialAccount extends Model implements Loggable
{ {
protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps']; protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
public function user() public function user()
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
/**
* {@inheritdoc}
*/
public function logDescriptor(): string
{
return "{$this->driver}; {$this->user->logDescriptor()}";
}
} }

View File

@@ -1,208 +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\Model;
use BookStack\Notifications\ResetPassword; use BookStack\Notifications\ResetPassword;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use Carbon\Carbon; use Carbon\Carbon;
use Exception;
use Illuminate\Auth\Authenticatable; use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; 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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
/** /**
* Class User. * Class User
* * @package BookStack\Auth
* @property int $id * @property string $id
* @property string $name * @property string $name
* @property string $slug * @property string $email
* @property string $email * @property string $password
* @property string $password * @property Carbon $created_at
* @property Carbon $created_at * @property Carbon $updated_at
* @property Carbon $updated_at * @property bool $email_confirmed
* @property bool $email_confirmed * @property int $image_id
* @property int $image_id * @property string $external_auth_id
* @property string $external_auth_id * @property string $system_name
* @property string $system_name
* @property Collection $roles
* @property Collection $mfaValues
*/ */
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable class User extends Model implements AuthenticatableContract, CanResetPasswordContract
{ {
use HasFactory; use Authenticatable, CanResetPassword, Notifiable;
use Authenticatable;
use CanResetPassword;
use Notifiable;
/** /**
* The database table used by the model. * The database table used by the model.
*
* @var string * @var string
*/ */
protected $table = 'users'; protected $table = 'users';
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
*
* @var array * @var array
*/ */
protected $fillable = ['name', 'email']; protected $fillable = ['name', 'email'];
protected $casts = ['last_activity_at' => 'datetime'];
/** /**
* The attributes excluded from the model's JSON form. * The attributes excluded from the model's JSON form.
*
* @var array * @var array
*/ */
protected $hidden = [ protected $hidden = ['password', 'remember_token'];
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
'created_at', 'updated_at', 'image_id',
];
/** /**
* This holds the user's permissions when loaded. * This holds the user's permissions when loaded.
* * @var array
* @var ?Collection
*/ */
protected $permissions; protected $permissions;
/**
* This holds the default user when loaded.
*
* @var null|User
*/
protected static $defaultUser = null;
/** /**
* Returns the default public user. * Returns the default public user.
* @return User
*/ */
public static function getDefault(): self public static function getDefault()
{ {
if (!is_null(static::$defaultUser)) { return static::where('system_name', '=', 'public')->first();
return static::$defaultUser;
}
static::$defaultUser = static::query()->where('system_name', '=', 'public')->first();
return static::$defaultUser;
} }
/** /**
* Check if the user is the default public user. * Check if the user is the default public user.
* @return bool
*/ */
public function isDefault(): bool public function isDefault()
{ {
return $this->system_name === 'public'; return $this->system_name === 'public';
} }
/** /**
* The roles that belong to the user. * The roles that belong to the user.
*
* @return BelongsToMany * @return BelongsToMany
*/ */
public function roles() public function roles()
{ {
if ($this->id === 0) { if ($this->id === 0) {
return; return ;
} }
return $this->belongsToMany(Role::class); return $this->belongsToMany(Role::class);
} }
/** /**
* Check if the user has a role. * 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. * 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.
*/
public function attachDefaultRole(): void
{
$roleId = setting('registration-role');
if ($roleId && $this->roles()->where('id', '=', $roleId)->count() === 0) {
$this->roles()->attach($roleId);
}
}
/**
* Check if the user has a particular permission.
*/
public function can(string $permissionName): bool
{
if ($this->email === 'guest') {
return false;
}
return $this->permissions()->contains($permissionName);
} }
/** /**
* Get all permissions belonging to a the current user. * Get all permissions belonging to a the current user.
* @param bool $cache
* @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
*/ */
protected function permissions(): Collection public function permissions($cache = true)
{ {
if (isset($this->permissions)) { if (isset($this->permissions) && $cache) {
return $this->permissions; return $this->permissions;
} }
$this->load('roles.permissions');
$this->permissions = $this->newQuery()->getConnection()->table('role_user', 'ru') $permissions = $this->roles->map(function ($role) {
->select('role_permissions.name as name')->distinct() return $role->permissions;
->leftJoin('permission_role', 'ru.role_id', '=', 'permission_role.role_id') })->flatten()->unique();
->leftJoin('role_permissions', 'permission_role.permission_id', '=', 'role_permissions.id') $this->permissions = $permissions;
->where('ru.user_id', '=', $this->id) return $permissions;
->pluck('name');
return $this->permissions;
} }
/** /**
* Clear any cached permissions on this instance. * Check if the user has a particular permission.
* @param $permissionName
* @return bool
*/ */
public function clearPermissionCache() public function can($permissionName)
{ {
$this->permissions = null; if ($this->email === 'guest') {
return false;
}
return $this->permissions()->pluck('name')->contains($permissionName);
} }
/** /**
* Attach a role to this user. * Attach a role to this user.
* @param Role $role
*/ */
public function attachRole(Role $role) public function attachRole(Role $role)
{ {
$this->roles()->attach($role->id); $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. * 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); return $this->hasMany(SocialAccount::class);
} }
@@ -210,9 +164,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/** /**
* Check if the user has a social account, * Check if the user has a social account,
* If a driver is passed it checks for that single account type. * If a driver is passed it checks for that single account type.
*
* @param bool|string $socialDriver * @param bool|string $socialDriver
*
* @return bool * @return bool
*/ */
public function hasSocialAccount($socialDriver = false) public function hasSocialAccount($socialDriver = false)
@@ -225,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'); $default = url('/user_avatar.png');
$imageId = $this->image_id; $imageId = $this->image_id;
@@ -237,80 +191,45 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
try { try {
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default; $avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
} catch (Exception $err) { } catch (\Exception $err) {
$avatar = $default; $avatar = $default;
} }
return $avatar; return $avatar;
} }
/** /**
* Get the avatar for the user. * 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'); 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. * 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('/settings/users/' . $this->id);
return url(rtrim($uri, '/'));
} }
/** /**
* Get the url that links to this user's profile. * 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. * 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) { if (mb_strlen($this->name) <= $chars) {
return $this->name; return $this->name;
@@ -326,31 +245,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/** /**
* Send the password reset notification. * Send the password reset notification.
* * @param string $token
* @param string $token
*
* @return void * @return void
*/ */
public function sendPasswordResetNotification($token) public function sendPasswordResetNotification($token)
{ {
$this->notify(new ResetPassword($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,80 +1,70 @@
<?php <?php namespace BookStack\Auth;
namespace BookStack\Auth; use Activity;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\UserUpdateException; use BookStack\Exceptions\UserUpdateException;
use BookStack\Uploads\UserAvatars; use BookStack\Uploads\Image;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Images;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Log;
class UserRepo class UserRepo
{ {
protected $userAvatar;
protected $user;
protected $role;
protected $entityRepo;
/** /**
* UserRepo constructor. * UserRepo constructor.
* @param User $user
* @param Role $role
* @param EntityRepo $entityRepo
*/ */
public function __construct(UserAvatars $userAvatar) public function __construct(User $user, Role $role, EntityRepo $entityRepo)
{ {
$this->userAvatar = $userAvatar; $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.
*/
public function getBySlug(string $slug): User
{
return User::query()->where('slug', '=', $slug)->firstOrFail();
} }
/** /**
* Get all the users with their permissions. * Get all the users with their permissions.
* @return Builder|static
*/ */
public function getAllUsers(): Collection public function getAllUsers()
{ {
return User::query()->with('roles', 'avatar')->orderBy('name', 'asc')->get(); return $this->user->with('roles', 'avatar')->orderBy('name', 'asc')->get();
} }
/** /**
* Get all the users with their permissions in a paginated format. * 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 * @param int $count
* user is assumed to be trusted. (Admin users). * @param $sortData
* Email search can be abused to extract email addresses. * @return Builder|static
*/ */
public function getAllUsersPaginatedAndSorted(int $count, array $sortData): LengthAwarePaginator public function getAllUsersPaginatedAndSorted($count, $sortData)
{ {
$sort = $sortData['sort']; $query = $this->user->with('roles', 'avatar')->orderBy($sortData['sort'], $sortData['order']);
$query = User::query()->select(['*'])
->scopes(['withLastActivityAt'])
->with(['roles', 'avatar'])
->withCount('mfaValues')
->orderBy($sort, $sortData['order']);
if ($sortData['search']) { if ($sortData['search']) {
$term = '%' . $sortData['search'] . '%'; $term = '%' . $sortData['search'] . '%';
@@ -87,27 +77,43 @@ class UserRepo
return $query->paginate($count); return $query->paginate($count);
} }
/** /**
* Creates a new user and attaches a role to them. * 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, bool $emailConfirmed = false): User public function registerNew(array $data, $verifyEmail = false)
{ {
$user = $this->create($data, $emailConfirmed); $user = $this->create($data, $verifyEmail);
$user->attachDefaultRole(); $this->attachDefaultRole($user);
$this->downloadAndAssignUserAvatar($user); $this->downloadAndAssignUserAvatar($user);
return $user; return $user;
} }
/**
* Give a user the default role. Used when creating a new user.
* @param User $user
*/
public function attachDefaultRole(User $user)
{
$roleId = setting('registration-role');
if ($roleId !== false && $user->roles()->where('id', '=', $roleId)->count() === 0) {
$user->attachRoleId($roleId);
}
}
/** /**
* Assign a user to a system-level role. * Assign a user to a system-level role.
* * @param User $user
* @param $systemRoleName
* @throws NotFoundException * @throws NotFoundException
*/ */
public function attachSystemRole(User $user, string $systemRoleName) public function attachSystemRole(User $user, $systemRoleName)
{ {
$role = Role::getSystemRole($systemRoleName); $role = $this->role->newQuery()->where('system_name', '=', $systemRoleName)->first();
if (is_null($role)) { if ($role === null) {
throw new NotFoundException("Role '{$systemRoleName}' not found"); throw new NotFoundException("Role '{$systemRoleName}' not found");
} }
$user->attachRole($role); $user->attachRole($role);
@@ -115,24 +121,26 @@ class UserRepo
/** /**
* Checks if the give user is the only admin. * Checks if the give user is the only admin.
* @param \BookStack\Auth\User $user
* @return bool
*/ */
public function isOnlyAdmin(User $user): bool public function isOnlyAdmin(User $user)
{ {
if (!$user->hasSystemRole('admin')) { if (!$user->hasSystemRole('admin')) {
return false; return false;
} }
$adminRole = Role::getSystemRole('admin'); $adminRole = $this->role->getSystemRole('admin');
if ($adminRole->users()->count() > 1) { if ($adminRole->users->count() > 1) {
return false; return false;
} }
return true; return true;
} }
/** /**
* Set the assigned user roles via an array of role IDs. * Set the assigned user roles via an array of role IDs.
* * @param User $user
* @param array $roles
* @throws UserUpdateException * @throws UserUpdateException
*/ */
public function setUserRoles(User $user, array $roles) public function setUserRoles(User $user, array $roles)
@@ -147,11 +155,14 @@ class UserRepo
/** /**
* Check if the given user is the last admin and their new roles no longer * Check if the given user is the last admin and their new roles no longer
* contains the admin role. * 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)) { if ($this->isOnlyAdmin($user)) {
$adminRole = Role::getSystemRole('admin'); $adminRole = $this->role->getSystemRole('admin');
if (!in_array(strval($adminRole->id), $newRoles)) { if (!in_array(strval($adminRole->id), $newRoles)) {
return true; return true;
} }
@@ -162,114 +173,123 @@ class UserRepo
/** /**
* Create a new basic instance of user. * Create a new basic instance of user.
* @param array $data
* @param boolean $verifyEmail
* @return \BookStack\Auth\User
*/ */
public function create(array $data, bool $emailConfirmed = false): User public function create(array $data, $verifyEmail = false)
{ {
$details = [ return $this->user->forceCreate([
'name' => $data['name'], 'name' => $data['name'],
'email' => $data['email'], 'email' => $data['email'],
'password' => bcrypt($data['password']), 'password' => bcrypt($data['password']),
'email_confirmed' => $emailConfirmed, 'email_confirmed' => $verifyEmail
'external_auth_id' => $data['external_auth_id'] ?? '', ]);
];
$user = new User();
$user->forceFill($details);
$user->refreshSlug();
$user->save();
return $user;
} }
/** /**
* Remove the given user from storage, Delete all related content. * Remove the given user from storage, Delete all related content.
* * @param \BookStack\Auth\User $user
* @throws Exception * @throws Exception
*/ */
public function destroy(User $user, ?int $newOwnerId = null) public function destroy(User $user)
{ {
$user->socialAccounts()->delete(); $user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
$user->delete(); $user->delete();
// Delete user profile images // Delete user profile images
$this->userAvatar->destroyAllForUser($user); $profileImages = Image::where('type', '=', 'user')->where('uploaded_to', '=', $user->id)->get();
foreach ($profileImages as $image) {
if (!empty($newOwnerId)) { Images::destroy($image);
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {
$this->migrateOwnership($user, $newOwner);
}
} }
} }
/** /**
* Migrate ownership of items in the system from one user to another. * Get the latest activity for a user.
* @param \BookStack\Auth\User $user
* @param int $count
* @param int $page
* @return array
*/ */
protected function migrateOwnership(User $fromUser, User $toUser) public function getActivity(User $user, $count = 20, $page = 0)
{ {
$entities = (new EntityProvider())->all(); return Activity::userActivity($user, $count, $page);
foreach ($entities as $instance) {
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
->update(['owned_by' => $toUser->id]);
}
} }
/** /**
* Get the recently created content for this given user. * Get the recently created content for this given user.
* @param \BookStack\Auth\User $user
* @param int $count
* @return mixed
*/ */
public function getRecentlyCreated(User $user, int $count = 20): array public function getRecentlyCreated(User $user, $count = 20)
{ {
$query = function (Builder $query) use ($user, $count) { $createdByUserQuery = function (Builder $query) use ($user) {
return $query->orderBy('created_at', 'desc') $query->where('created_by', '=', $user->id);
->where('created_by', '=', $user->id)
->take($count)
->get();
}; };
return [ return [
'pages' => $query(Page::visible()->where('draft', '=', false)), 'pages' => $this->entityRepo->getRecentlyCreated('page', $count, 0, $createdByUserQuery),
'chapters' => $query(Chapter::visible()), 'chapters' => $this->entityRepo->getRecentlyCreated('chapter', $count, 0, $createdByUserQuery),
'books' => $query(Book::visible()), 'books' => $this->entityRepo->getRecentlyCreated('book', $count, 0, $createdByUserQuery),
'shelves' => $query(Bookshelf::visible()), 'shelves' => $this->entityRepo->getRecentlyCreated('bookshelf', $count, 0, $createdByUserQuery)
]; ];
} }
/** /**
* Get asset created counts for the give user. * Get asset created counts for the give user.
* @param \BookStack\Auth\User $user
* @return array
*/ */
public function getAssetCounts(User $user): array public function getAssetCounts(User $user)
{ {
$createdBy = ['created_by' => $user->id];
return [ return [
'pages' => Page::visible()->where($createdBy)->count(), 'pages' => $this->entityRepo->getUserTotalCreated('page', $user),
'chapters' => Chapter::visible()->where($createdBy)->count(), 'chapters' => $this->entityRepo->getUserTotalCreated('chapter', $user),
'books' => Book::visible()->where($createdBy)->count(), 'books' => $this->entityRepo->getUserTotalCreated('book', $user),
'shelves' => Bookshelf::visible()->where($createdBy)->count(), 'shelves' => $this->entityRepo->getUserTotalCreated('bookshelf', $user),
]; ];
} }
/** /**
* Get the roles in the system that are assignable to a user. * Get the roles in the system that are assignable to a user.
* @return mixed
*/ */
public function getAllRoles(): Collection public function getAllRoles()
{ {
return Role::query()->orderBy('display_name', 'asc')->get(); 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. * Get an avatar image for a user and set it as their avatar.
* Returns early if avatars disabled or not set in config. * Returns early if avatars disabled or not set in config.
* @param User $user
* @return bool
*/ */
public function downloadAndAssignUserAvatar(User $user): void public function downloadAndAssignUserAvatar(User $user)
{ {
if (!Images::avatarFetchEnabled()) {
return false;
}
try { try {
$this->userAvatar->fetchAndAssignToUser($user); $avatar = Images::saveUserAvatar($user);
$user->avatar()->associate($avatar);
$user->save();
return true;
} catch (Exception $e) { } catch (Exception $e) {
Log::error('Failed to save user avatar image'); \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),
];

123
app/Config/app.php Normal file → Executable file
View File

@@ -19,31 +19,23 @@ return [
// private configuration variables so should remain disabled in public. // private configuration variables so should remain disabled in public.
'debug' => env('APP_DEBUG', false), '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. // The number of revisions to keep in the database.
// Once this limit is reached older revisions will be deleted. // Once this limit is reached older revisions will be deleted.
// If set to false then a limit will not be enforced. // If set to false then a limit will not be enforced.
'revision_limit' => env('REVISION_LIMIT', 50), 'revision_limit' => env('REVISION_LIMIT', 50),
// The number of days that content will remain in the recycle bin before
// being considered for auto-removal. It is not a guarantee that content will
// 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),
// Allow <script> tags to entered within page content. // Allow <script> tags to entered within page content.
// <script> tags are escaped by default. // <script> tags are escaped by default.
// Even when overridden the WYSIWYG editor may still escape script content. // Even when overridden the WYSIWYG editor may still escape script content.
'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false), '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. // Override the default behaviour for allowing crawlers to crawl the instance.
// May be ignored if view has be overridden or modified. // May be ignored if view has be overridden or modified.
// Defaults to null since, if not set, 'app-public' status used instead. // Defaults to null since, if not set, 'app-public' status used instead.
@@ -53,10 +45,6 @@ return [
// and used by BookStack in URL generation. // and used by BookStack in URL generation.
'url' => env('APP_URL', '') === 'http://bookstack.dev' ? '' : env('APP_URL', ''), '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),
// Application timezone for back-end date functions. // Application timezone for back-end date functions.
'timezone' => env('APP_TIMEZONE', 'UTC'), 'timezone' => env('APP_TIMEZONE', 'UTC'),
@@ -64,14 +52,11 @@ return [
'locale' => env('APP_LANG', 'en'), 'locale' => env('APP_LANG', 'en'),
// Locales available // Locales available
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'], '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 // Application Fallback Locale
'fallback_locale' => 'en', 'fallback_locale' => 'en',
// Faker Locale
'faker_locale' => 'en_GB',
// Enable right-to-left text control. // Enable right-to-left text control.
'rtl' => false, 'rtl' => false,
@@ -87,6 +72,10 @@ return [
// Encryption cipher // Encryption cipher
'cipher' => 'AES-256-CBC', 'cipher' => 'AES-256-CBC',
// Logging configuration
// Options: single, daily, syslog, errorlog
'log' => env('APP_LOGGING', 'single'),
// Application Services Provides // Application Services Provides
'providers' => [ 'providers' => [
@@ -118,19 +107,18 @@ return [
Barryvdh\DomPDF\ServiceProvider::class, Barryvdh\DomPDF\ServiceProvider::class,
Barryvdh\Snappy\ServiceProvider::class, Barryvdh\Snappy\ServiceProvider::class,
// BookStack replacement service providers (Extends Laravel) // BookStack replacement service providers (Extends Laravel)
BookStack\Providers\PaginationServiceProvider::class, BookStack\Providers\PaginationServiceProvider::class,
BookStack\Providers\TranslationServiceProvider::class, BookStack\Providers\TranslationServiceProvider::class,
// BookStack custom service providers // BookStack custom service providers
BookStack\Providers\ThemeServiceProvider::class,
BookStack\Providers\AuthServiceProvider::class, BookStack\Providers\AuthServiceProvider::class,
BookStack\Providers\AppServiceProvider::class, BookStack\Providers\AppServiceProvider::class,
BookStack\Providers\BroadcastServiceProvider::class, BookStack\Providers\BroadcastServiceProvider::class,
BookStack\Providers\EventServiceProvider::class, BookStack\Providers\EventServiceProvider::class,
BookStack\Providers\RouteServiceProvider::class, BookStack\Providers\RouteServiceProvider::class,
BookStack\Providers\CustomFacadeProvider::class, BookStack\Providers\CustomFacadeProvider::class,
BookStack\Providers\CustomValidationServiceProvider::class,
], ],
/* /*
@@ -146,57 +134,54 @@ return [
// Class aliases, Registered on application start // Class aliases, Registered on application start
'aliases' => [ '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 // Laravel
'Socialite' => Laravel\Socialite\Facades\Socialite::class, '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 // Third Party
'ImageTool' => Intervention\Image\Facades\Image::class, 'ImageTool' => Intervention\Image\Facades\Image::class,
'DomPDF' => Barryvdh\DomPDF\Facade::class, 'DomPDF' => Barryvdh\DomPDF\Facade::class,
'SnappyPDF' => Barryvdh\Snappy\Facades\SnappyPdf::class, 'SnappyPDF' => Barryvdh\Snappy\Facades\SnappyPdf::class,
// Custom BookStack // Custom BookStack
'Activity' => BookStack\Facades\Activity::class, 'Activity' => BookStack\Facades\Activity::class,
'Permissions' => BookStack\Facades\Permissions::class, 'Setting' => BookStack\Facades\Setting::class,
'Theme' => BookStack\Facades\Theme::class, 'Views' => BookStack\Facades\Views::class,
'Images' => BookStack\Facades\Images::class,
], ],
// Proxy configuration // Proxy configuration

View File

@@ -10,14 +10,15 @@
return [ return [
// Options: standard, ldap, saml2, oidc // Method of authentication to use
// Options: standard, ldap
'method' => env('AUTH_METHOD', 'standard'), 'method' => env('AUTH_METHOD', 'standard'),
// Authentication Defaults // Authentication Defaults
// This option controls the default authentication "guard" and password // This option controls the default authentication "guard" and password
// reset options for your application. // reset options for your application.
'defaults' => [ 'defaults' => [
'guard' => env('AUTH_METHOD', 'standard'), 'guard' => 'web',
'passwords' => 'users', 'passwords' => 'users',
], ],
@@ -25,26 +26,16 @@ return [
// All authentication drivers have a user provider. This defines how the // All authentication drivers have a user provider. This defines how the
// users are actually retrieved out of your database or other storage // users are actually retrieved out of your database or other storage
// mechanisms used by this application to persist your user's data. // 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' => [ 'guards' => [
'standard' => [ 'web' => [
'driver' => 'session', 'driver' => 'session',
'provider' => 'users', 'provider' => 'users',
], ],
'ldap' => [
'driver' => 'ldap-session',
'provider' => 'external',
],
'saml2' => [
'driver' => 'async-external-session',
'provider' => 'external',
],
'oidc' => [
'driver' => 'async-external-session',
'provider' => 'external',
],
'api' => [ 'api' => [
'driver' => 'api-token', 'driver' => 'token',
'provider' => 'users',
], ],
], ],
@@ -52,15 +43,11 @@ return [
// All authentication drivers have a user provider. This defines how the // All authentication drivers have a user provider. This defines how the
// users are actually retrieved out of your database or other storage // users are actually retrieved out of your database or other storage
// mechanisms used by this application to persist your user's data. // mechanisms used by this application to persist your user's data.
// Supported: database, eloquent, ldap
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent', 'driver' => env('AUTH_METHOD', 'standard') === 'standard' ? 'eloquent' : env('AUTH_METHOD'),
'model' => \BookStack\Auth\User::class, 'model' => \BookStack\Auth\User::class,
],
'external' => [
'driver' => 'external-users',
'model' => \BookStack\Auth\User::class,
], ],
// 'users' => [ // 'users' => [
@@ -76,17 +63,10 @@ return [
'passwords' => [ 'passwords' => [
'users' => [ 'users' => [
'provider' => 'users', 'provider' => 'users',
'email' => 'emails.password', 'email' => 'emails.password',
'table' => 'password_resets', 'table' => 'password_resets',
'expire' => 60, 'expire' => 60,
'throttle' => 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' => [ 'connections' => [
'pusher' => [ 'pusher' => [
'driver' => 'pusher', 'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'), 'key' => env('PUSHER_KEY'),
'secret' => env('PUSHER_APP_SECRET'), 'secret' => env('PUSHER_SECRET'),
'app_id' => env('PUSHER_APP_ID'), 'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'useTLS' => true,
],
], ],
'redis' => [ 'redis' => [
'driver' => 'redis', 'driver' => 'redis',
'connection' => 'default', 'connection' => 'default',
], ],
@@ -42,10 +38,6 @@ return [
'driver' => 'log', 'driver' => 'log',
], ],
'null' => [
'driver' => 'null',
],
], ],
]; ];

View File

@@ -1,7 +1,5 @@
<?php <?php
use Illuminate\Support\Str;
/** /**
* Caching configuration options. * Caching configuration options.
* *
@@ -16,12 +14,8 @@ if (env('CACHE_DRIVER') === 'memcached') {
$memcachedServers = explode(',', trim(env('MEMCACHED_SERVERS', '127.0.0.1:11211:100'), ',')); $memcachedServers = explode(',', trim(env('MEMCACHED_SERVERS', '127.0.0.1:11211:100'), ','));
foreach ($memcachedServers as $index => $memcachedServer) { foreach ($memcachedServers as $index => $memcachedServer) {
$memcachedServerDetails = explode(':', $memcachedServer); $memcachedServerDetails = explode(':', $memcachedServer);
if (count($memcachedServerDetails) < 2) { if (count($memcachedServerDetails) < 2) $memcachedServerDetails[] = '11211';
$memcachedServerDetails[] = '11211'; if (count($memcachedServerDetails) < 3) $memcachedServerDetails[] = '100';
}
if (count($memcachedServerDetails) < 3) {
$memcachedServerDetails[] = '100';
}
$memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails); $memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails);
} }
} }
@@ -40,15 +34,13 @@ return [
], ],
'array' => [ 'array' => [
'driver' => 'array', 'driver' => 'array',
'serialize' => false,
], ],
'database' => [ 'database' => [
'driver' => 'database', 'driver' => 'database',
'table' => 'cache', 'table' => 'cache',
'connection' => null, 'connection' => null,
'lock_connection' => null,
], ],
'file' => [ 'file' => [
@@ -57,36 +49,19 @@ return [
], ],
'memcached' => [ 'memcached' => [
'driver' => 'memcached', 'driver' => 'memcached',
'options' => [ 'servers' => env('CACHE_DRIVER') === 'memcached' ? $memcachedServers : [],
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => $memcachedServers ?? [],
], ],
'redis' => [ 'redis' => [
'driver' => 'redis', 'driver' => 'redis',
'connection' => 'default', 'connection' => 'default',
'lock_connection' => 'default',
],
'octane' => [
'driver' => 'octane',
], ],
], ],
/* // Cache key prefix
|-------------------------------------------------------------------------- // Used to prevent collisions in shared cache systems.
| Cache Key Prefix 'prefix' => env('CACHE_PREFIX', 'bookstack'),
|--------------------------------------------------------------------------
|
| 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'),
]; ];

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

View File

@@ -11,9 +11,10 @@
// REDIS // REDIS
// Split out configuration into an array // Split out configuration into an array
if (env('REDIS_SERVERS', false)) { if (env('REDIS_SERVERS', false)) {
$redisDefaults = ['host' => '127.0.0.1', 'port' => '6379', 'database' => '0', 'password' => null]; $redisDefaults = ['host' => '127.0.0.1', 'port' => '6379', 'database' => '0', 'password' => null];
$redisServers = explode(',', trim(env('REDIS_SERVERS', '127.0.0.1:6379:0'), ',')); $redisServers = explode(',', trim(env('REDIS_SERVERS', '127.0.0.1:6379:0'), ','));
$redisConfig = ['client' => 'predis']; $redisConfig = [];
$cluster = count($redisServers) > 1; $cluster = count($redisServers) > 1;
if ($cluster) { if ($cluster) {
@@ -58,42 +59,58 @@ return [
// Many of those shown here are unsupported by BookStack. // Many of those shown here are unsupported by BookStack.
'connections' => [ 'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'database' => storage_path('database.sqlite'),
'prefix' => '',
],
'mysql' => [ 'mysql' => [
'driver' => 'mysql', 'driver' => 'mysql',
'url' => env('DATABASE_URL'), 'host' => $mysql_host,
'host' => $mysql_host, 'database' => env('DB_DATABASE', 'forge'),
'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'),
'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''),
'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''),
'unix_socket' => env('DB_SOCKET', ''), 'port' => $mysql_port,
'port' => $mysql_port, 'charset' => 'utf8mb4',
'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci',
'collation' => 'utf8mb4_unicode_ci', 'prefix' => '',
// Prefixes are only semi-supported and may be unstable 'strict' => false,
// since they are not tested as part of our automated test suite. 'engine' => null,
// If used, the prefix should not be changed otherwise you will likely receive errors.
'prefix' => env('DB_TABLE_PREFIX', ''),
'prefix_indexes' => true,
'strict' => false,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
], ],
'mysql_testing' => [ 'mysql_testing' => [
'driver' => 'mysql', 'driver' => 'mysql',
'url' => env('TEST_DATABASE_URL'), 'host' => '127.0.0.1',
'host' => '127.0.0.1', 'database' => 'bookstack-test',
'database' => 'bookstack-test', 'username' => env('MYSQL_USER', 'bookstack-test'),
'username' => env('MYSQL_USER', 'bookstack-test'), 'password' => env('MYSQL_PASSWORD', 'bookstack-test'),
'password' => env('MYSQL_PASSWORD', 'bookstack-test'), 'charset' => 'utf8',
'port' => $mysql_port, 'collation' => 'utf8_unicode_ci',
'charset' => 'utf8mb4', 'prefix' => '',
'collation' => 'utf8mb4_unicode_ci', 'strict' => false,
'prefix' => '', ],
'prefix_indexes' => true,
'strict' => false, 'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', 'localhost'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'schema' => 'public',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'host' => env('DB_HOST', 'localhost'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
], ],
], ],
@@ -105,6 +122,6 @@ return [
'migrations' => 'migrations', 'migrations' => 'migrations',
// Redis configuration to use if set // Redis configuration to use if set
'redis' => $redisConfig ?? [], 'redis' => env('REDIS_SERVERS', false) ? $redisConfig : [],
]; ];

View File

@@ -1,7 +1,7 @@
<?php <?php
/** /**
* Debugbar Configuration Options. * Debugbar Configuration Options
* *
* Changes to these config files are not supported by BookStack and may break upon updates. * 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. * Configuration should be altered via the `.env` file or environment variables.
@@ -10,52 +10,53 @@
return [ return [
// Debugbar is enabled by default, when debug is set to true in app.php. // Debugbar is enabled by default, when debug is set to true in app.php.
// You can override the value by setting enable to true or false instead of null. // You can override the value by setting enable to true or false instead of null.
// //
// You can provide an array of URI's that must be ignored (eg. 'api/*') // You can provide an array of URI's that must be ignored (eg. 'api/*')
'enabled' => env('DEBUGBAR_ENABLED', false), 'enabled' => env('DEBUGBAR_ENABLED', false),
'except' => [ 'except' => [
'telescope*', 'telescope*'
], ],
// DebugBar stores data for session/ajax requests.
// You can disable this, so the debugbar stores data in headers/session, // DebugBar stores data for session/ajax requests.
// but this can cause problems with large data collectors. // You can disable this, so the debugbar stores data in headers/session,
// By default, file storage (in the storage folder) is used. Redis and PDO // but this can cause problems with large data collectors.
// can also be used. For PDO, run the package migrations first. // By default, file storage (in the storage folder) is used. Redis and PDO
// can also be used. For PDO, run the package migrations first.
'storage' => [ 'storage' => [
'enabled' => true, 'enabled' => true,
'driver' => 'file', // redis, file, pdo, custom 'driver' => 'file', // redis, file, pdo, custom
'path' => storage_path('debugbar'), // For file driver 'path' => storage_path('debugbar'), // For file driver
'connection' => null, // Leave null for default connection (Redis/PDO) 'connection' => null, // Leave null for default connection (Redis/PDO)
'provider' => '', // Instance of StorageInterface for custom driver 'provider' => '' // Instance of StorageInterface for custom driver
], ],
// Vendor files are included by default, but can be set to false. // Vendor files are included by default, but can be set to false.
// This can also be set to 'js' or 'css', to only include javascript or css vendor files. // This can also be set to 'js' or 'css', to only include javascript or css vendor files.
// Vendor files are for css: font-awesome (including fonts) and highlight.js (css files) // Vendor files are for css: font-awesome (including fonts) and highlight.js (css files)
// and for js: jquery and and highlight.js // and for js: jquery and and highlight.js
// So if you want syntax highlighting, set it to true. // So if you want syntax highlighting, set it to true.
// jQuery is set to not conflict with existing jQuery scripts. // jQuery is set to not conflict with existing jQuery scripts.
'include_vendors' => true, 'include_vendors' => true,
// The Debugbar can capture Ajax requests and display them. If you don't want this (ie. because of errors), // The Debugbar can capture Ajax requests and display them. If you don't want this (ie. because of errors),
// you can use this option to disable sending the data through the headers. // you can use this option to disable sending the data through the headers.
// Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools. // Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools.
'capture_ajax' => true, 'capture_ajax' => true,
'add_ajax_timing' => false, 'add_ajax_timing' => false,
// When enabled, the Debugbar shows deprecated warnings for Symfony components // When enabled, the Debugbar shows deprecated warnings for Symfony components
// in the Messages tab. // in the Messages tab.
'error_handler' => false, 'error_handler' => false,
// The Debugbar can emulate the Clockwork headers, so you can use the Chrome // The Debugbar can emulate the Clockwork headers, so you can use the Chrome
// Extension, without the server-side code. It uses Debugbar collectors instead. // Extension, without the server-side code. It uses Debugbar collectors instead.
'clockwork' => false, 'clockwork' => false,
// Enable/disable DataCollectors // Enable/disable DataCollectors
'collectors' => [ 'collectors' => [
'phpinfo' => true, // Php version 'phpinfo' => true, // Php version
'messages' => true, // Messages 'messages' => true, // Messages
@@ -78,10 +79,9 @@ return [
'files' => false, // Show the included files 'files' => false, // Show the included files
'config' => false, // Display config settings 'config' => false, // Display config settings
'cache' => false, // Display cache events 'cache' => false, // Display cache events
'models' => true, // Display models
], ],
// Configure some DataCollectors // Configure some DataCollectors
'options' => [ 'options' => [
'auth' => [ 'auth' => [
'show_name' => true, // Also show the users name/email in the debugbar 'show_name' => true, // Also show the users name/email in the debugbar
@@ -90,43 +90,43 @@ return [
'with_params' => true, // Render SQL with the parameters substituted 'with_params' => true, // Render SQL with the parameters substituted
'backtrace' => true, // Use a backtrace to find the origin of the query in your files. 'backtrace' => true, // Use a backtrace to find the origin of the query in your files.
'timeline' => false, // Add the queries to the timeline 'timeline' => false, // Add the queries to the timeline
'explain' => [ // Show EXPLAIN output on queries 'explain' => [ // Show EXPLAIN output on queries
'enabled' => false, 'enabled' => false,
'types' => ['SELECT'], // ['SELECT', 'INSERT', 'UPDATE', 'DELETE']; for MySQL 5.6.3+ 'types' => ['SELECT'], // ['SELECT', 'INSERT', 'UPDATE', 'DELETE']; for MySQL 5.6.3+
], ],
'hints' => true, // Show hints for common mistakes 'hints' => true, // Show hints for common mistakes
], ],
'mail' => [ 'mail' => [
'full_log' => false, 'full_log' => false
], ],
'views' => [ 'views' => [
'data' => false, //Note: Can slow down the application, because the data can be quite large.. 'data' => false, //Note: Can slow down the application, because the data can be quite large..
], ],
'route' => [ 'route' => [
'label' => true, // show complete route on bar 'label' => true // show complete route on bar
], ],
'logs' => [ 'logs' => [
'file' => null, 'file' => null
], ],
'cache' => [ 'cache' => [
'values' => true, // collect cache values 'values' => true // collect cache values
], ],
], ],
// Inject Debugbar into the response // Inject Debugbar into the response
// Usually, the debugbar is added just before </body>, by listening to the // Usually, the debugbar is added just before </body>, by listening to the
// Response after the App is done. If you disable this, you have to add them // Response after the App is done. If you disable this, you have to add them
// in your template yourself. See http://phpdebugbar.com/docs/rendering.html // in your template yourself. See http://phpdebugbar.com/docs/rendering.html
'inject' => true, 'inject' => true,
// DebugBar route prefix // DebugBar route prefix
// Sometimes you want to set route prefix to be used by DebugBar to load // Sometimes you want to set route prefix to be used by DebugBar to load
// its resources from. Usually the need comes from misconfigured web server or // its resources from. Usually the need comes from misconfigured web server or
// from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97 // from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97
'route_prefix' => '_debugbar', 'route_prefix' => '_debugbar',
// DebugBar route domain // DebugBar route domain
// By default DebugBar route served from the same domain that request served. // By default DebugBar route served from the same domain that request served.
// To override default domain, specify it as a non-empty value. // To override default domain, specify it as a non-empty value.
'route_domain' => env('APP_URL', '') === 'http://bookstack.dev' ? '' : env('APP_URL', ''), 'route_domain' => env('APP_URL', '') === 'http://bookstack.dev' ? '' : env('APP_URL', ''),
]; ];

View File

@@ -10,11 +10,12 @@
return [ return [
'show_warnings' => false, // Throw an Exception on warnings from dompdf 'show_warnings' => false, // Throw an Exception on warnings from dompdf
'orientation' => 'portrait', 'orientation' => 'portrait',
'defines' => [ 'defines' => [
/** /**
* The location of the DOMPDF font directory. * The location of the DOMPDF font directory
* *
* The location of the directory where DOMPDF will store fonts and font metrics * The location of the directory where DOMPDF will store fonts and font metrics
* Note: This directory must exist and be writable by the webserver process. * Note: This directory must exist and be writable by the webserver process.
@@ -37,17 +38,17 @@ return [
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic, * Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
* Symbol, ZapfDingbats. * Symbol, ZapfDingbats.
*/ */
'font_dir' => storage_path('fonts/'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782) "DOMPDF_FONT_DIR" => app_path('vendor/dompdf/dompdf/lib/fonts/'), //storage_path('fonts/'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
/** /**
* The location of the DOMPDF font cache directory. * The location of the DOMPDF font cache directory
* *
* This directory contains the cached font metrics for the fonts used by DOMPDF. * This directory contains the cached font metrics for the fonts used by DOMPDF.
* This directory can be the same as DOMPDF_FONT_DIR * This directory can be the same as DOMPDF_FONT_DIR
* *
* Note: This directory must exist and be writable by the webserver process. * Note: This directory must exist and be writable by the webserver process.
*/ */
'font_cache' => storage_path('fonts/'), "DOMPDF_FONT_CACHE" => storage_path('fonts/'),
/** /**
* The location of a temporary directory. * The location of a temporary directory.
@@ -56,10 +57,10 @@ return [
* The temporary directory is required to download remote images and when * The temporary directory is required to download remote images and when
* using the PFDLib back end. * using the PFDLib back end.
*/ */
'temp_dir' => sys_get_temp_dir(), "DOMPDF_TEMP_DIR" => sys_get_temp_dir(),
/** /**
* ==== IMPORTANT ====. * ==== IMPORTANT ====
* *
* dompdf's "chroot": Prevents dompdf from accessing system files or other * dompdf's "chroot": Prevents dompdf from accessing system files or other
* files on the webserver. All local files opened by dompdf must be in a * files on the webserver. All local files opened by dompdf must be in a
@@ -68,9 +69,9 @@ return [
* should be an absolute path. * should be an absolute path.
* This is only checked on command line call by dompdf.php, but not by * This is only checked on command line call by dompdf.php, but not by
* direct class use like: * direct class use like:
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output(); * $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
*/ */
'chroot' => realpath(public_path()), "DOMPDF_CHROOT" => realpath(base_path()),
/** /**
* Whether to use Unicode fonts or not. * Whether to use Unicode fonts or not.
@@ -81,19 +82,20 @@ return [
* When enabled, dompdf can support all Unicode glyphs. Any glyphs used in a * When enabled, dompdf can support all Unicode glyphs. Any glyphs used in a
* document must be present in your fonts, however. * document must be present in your fonts, however.
*/ */
'unicode_enabled' => true, "DOMPDF_UNICODE_ENABLED" => true,
/** /**
* Whether to enable font subsetting or not. * Whether to enable font subsetting or not.
*/ */
'enable_fontsubsetting' => false, "DOMPDF_ENABLE_FONTSUBSETTING" => false,
/** /**
* The PDF rendering backend to use. * The PDF rendering backend to use
* *
* Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and * Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and
* 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will * 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will
* fall back on CPDF. 'GD' renders PDFs to graphic files. {@link * Canvas_Factory} ultimately determines which rendering class to instantiate * fall back on CPDF. 'GD' renders PDFs to graphic files. {@link
* Canvas_Factory} ultimately determines which rendering class to instantiate
* based on this setting. * based on this setting.
* *
* Both PDFLib & CPDF rendering backends provide sufficient rendering * Both PDFLib & CPDF rendering backends provide sufficient rendering
@@ -115,10 +117,10 @@ return [
* @link http://www.ros.co.nz/pdf * @link http://www.ros.co.nz/pdf
* @link http://www.php.net/image * @link http://www.php.net/image
*/ */
'pdf_backend' => 'CPDF', "DOMPDF_PDF_BACKEND" => "CPDF",
/** /**
* PDFlib license key. * PDFlib license key
* *
* If you are using a licensed, commercial version of PDFlib, specify * If you are using a licensed, commercial version of PDFlib, specify
* your license key here. If you are using PDFlib-Lite or are evaluating * your license key here. If you are using PDFlib-Lite or are evaluating
@@ -141,7 +143,7 @@ return [
* the desired content might be different (e.g. screen or projection view of html file). * the desired content might be different (e.g. screen or projection view of html file).
* Therefore allow specification of content here. * Therefore allow specification of content here.
*/ */
'default_media_type' => 'print', "DOMPDF_DEFAULT_MEDIA_TYPE" => "print",
/** /**
* The default paper size. * The default paper size.
@@ -150,19 +152,18 @@ return [
* *
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.) * @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/ */
'default_paper_size' => 'a4', "DOMPDF_DEFAULT_PAPER_SIZE" => "a4",
/** /**
* The default font family. * The default font family
* *
* Used if no suitable fonts can be found. This must exist in the font folder. * Used if no suitable fonts can be found. This must exist in the font folder.
*
* @var string * @var string
*/ */
'default_font' => 'dejavu sans', "DOMPDF_DEFAULT_FONT" => "dejavu sans",
/** /**
* Image DPI setting. * Image DPI setting
* *
* This setting determines the default DPI setting for images and fonts. The * This setting determines the default DPI setting for images and fonts. The
* DPI may be overridden for inline images by explictly setting the * DPI may be overridden for inline images by explictly setting the
@@ -194,10 +195,10 @@ return [
* *
* @var int * @var int
*/ */
'dpi' => 96, "DOMPDF_DPI" => 96,
/** /**
* Enable inline PHP. * Enable inline PHP
* *
* If this setting is set to true then DOMPDF will automatically evaluate * If this setting is set to true then DOMPDF will automatically evaluate
* inline PHP contained within <script type="text/php"> ... </script> tags. * inline PHP contained within <script type="text/php"> ... </script> tags.
@@ -208,20 +209,20 @@ return [
* *
* @var bool * @var bool
*/ */
'enable_php' => false, "DOMPDF_ENABLE_PHP" => false,
/** /**
* Enable inline Javascript. * Enable inline Javascript
* *
* If this setting is set to true then DOMPDF will automatically insert * If this setting is set to true then DOMPDF will automatically insert
* JavaScript code contained within <script type="text/javascript"> ... </script> tags. * JavaScript code contained within <script type="text/javascript"> ... </script> tags.
* *
* @var bool * @var bool
*/ */
'enable_javascript' => false, "DOMPDF_ENABLE_JAVASCRIPT" => true,
/** /**
* Enable remote file access. * Enable remote file access
* *
* If this setting is set to true, DOMPDF will access remote sites for * If this setting is set to true, DOMPDF will access remote sites for
* images and CSS files as required. * images and CSS files as required.
@@ -237,27 +238,29 @@ return [
* *
* @var bool * @var bool
*/ */
'enable_remote' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false), "DOMPDF_ENABLE_REMOTE" => true,
/** /**
* A ratio applied to the fonts height to be more like browsers' line height. * A ratio applied to the fonts height to be more like browsers' line height
*/ */
'font_height_ratio' => 1.1, "DOMPDF_FONT_HEIGHT_RATIO" => 1.1,
/** /**
* Enable CSS float. * Enable CSS float
* *
* Allows people to disabled CSS float support * Allows people to disabled CSS float support
*
* @var bool * @var bool
*/ */
'enable_css_float' => true, "DOMPDF_ENABLE_CSS_FLOAT" => true,
/** /**
* Use the more-than-experimental HTML5 Lib parser. * Use the more-than-experimental HTML5 Lib parser
*/ */
'enable_html5parser' => true, "DOMPDF_ENABLE_HTML5PARSER" => true,
], ],
]; ];

View File

@@ -25,45 +25,50 @@ return [
// file storage service, such as s3, to store publicly accessible assets. // file storage service, such as s3, to store publicly accessible assets.
'url' => env('STORAGE_URL', false), 'url' => env('STORAGE_URL', false),
// Default Cloud Filesystem Disk
'cloud' => 's3',
// Available filesystem disks // Available filesystem disks
// Only local, local_secure & s3 are supported by BookStack // Only local, local_secure & s3 are supported by BookStack
'disks' => [ 'disks' => [
'local' => [ 'local' => [
'driver' => 'local',
'root' => public_path(),
'visibility' => 'public',
],
'local_secure_attachments' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('uploads/files/'), 'root' => public_path(),
], ],
'local_secure_images' => [ 'local_secure' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('uploads/images/'), 'root' => storage_path(),
'visibility' => 'public', ],
'ftp' => [
'driver' => 'ftp',
'host' => 'ftp.example.com',
'username' => 'your-username',
'password' => 'your-password',
], ],
's3' => [ 's3' => [
'driver' => 's3', 'driver' => 's3',
'key' => env('STORAGE_S3_KEY', 'your-key'), 'key' => env('STORAGE_S3_KEY', 'your-key'),
'secret' => env('STORAGE_S3_SECRET', 'your-secret'), 'secret' => env('STORAGE_S3_SECRET', 'your-secret'),
'region' => env('STORAGE_S3_REGION', 'your-region'), 'region' => env('STORAGE_S3_REGION', 'your-region'),
'bucket' => env('STORAGE_S3_BUCKET', 'your-bucket'), 'bucket' => env('STORAGE_S3_BUCKET', 'your-bucket'),
'endpoint' => env('STORAGE_S3_ENDPOINT', null), 'endpoint' => env('STORAGE_S3_ENDPOINT', null),
'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null, 'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null,
], ],
'rackspace' => [
'driver' => 'rackspace',
'username' => 'your-username',
'key' => 'your-key',
'container' => 'your-container',
'endpoint' => 'https://identity.api.rackspacecloud.com/v2.0/',
'region' => 'IAD',
'url_type' => 'publicURL',
],
], ],
// Symbolic Links
// Here you may configure the symbolic links that will be created when the
// `storage:link` Artisan command is executed. The array keys should be
// the locations of the links and the values should be their targets.
'links' => [
public_path('storage') => storage_path('app/public'),
],
]; ];

View File

@@ -1,37 +0,0 @@
<?php
/**
* Hashing 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 [
// Default Hash Driver
// This option controls the default hash driver that will be used to hash
// passwords for your application. By default, the bcrypt algorithm is used.
// Supported: "bcrypt", "argon", "argon2id"
'driver' => 'bcrypt',
// Bcrypt Options
// Here you may specify the configuration options that should be used when
// passwords are hashed using the Bcrypt algorithm. This will allow you
// to control the amount of time it takes to hash the given password.
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 10),
],
// Argon Options
// Here you may specify the configuration options that should be used when
// passwords are hashed using the Argon algorithm. These will allow you
// to control the amount of time it takes to hash the given password.
'argon' => [
'memory' => 1024,
'threads' => 2,
'time' => 2,
],
];

View File

@@ -1,108 +0,0 @@
<?php
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\ErrorLogHandler;
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
/**
* Logging 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 [
// Default Log Channel
// This option defines the default log channel that gets used when writing
// messages to the logs. The name specified in this option should match
// one of the channels defined in the "channels" configuration array.
'default' => env('LOG_CHANNEL', 'single'),
// Log Channels
// Here you may configure the log channels for your application. Out of
// the box, Laravel uses the Monolog PHP logging library. This gives
// you a variety of powerful log handlers / formatters to utilize.
// Available Drivers: "single", "daily", "slack", "syslog",
// "errorlog", "monolog",
// "custom", "stack"
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily'],
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 14,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 7,
],
'stderr' => [
'driver' => 'monolog',
'level' => 'debug',
'handler' => StreamHandler::class,
'with' => [
'stream' => 'php://stderr',
],
],
'syslog' => [
'driver' => 'syslog',
'level' => 'debug',
],
'errorlog' => [
'driver' => 'errorlog',
'level' => 'debug',
],
// Custom errorlog implementation that logs out a plain,
// non-formatted message intended for the webserver log.
'errorlog_plain_webserver' => [
'driver' => 'monolog',
'level' => 'debug',
'handler' => ErrorLogHandler::class,
'handler_with' => [4],
'formatter' => LineFormatter::class,
'formatter_with' => [
'format' => '%message%',
],
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
// Testing channel
// Uses a shared testing instance during tests
// so that logs can be checked against.
'testing' => [
'driver' => 'testing',
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
// Failed Login Message
// Allows a configurable message to be logged when a login request fails.
'failed_login' => [
'message' => env('LOG_FAILED_LOGIN_MESSAGE', null),
'channel' => env('LOG_FAILED_LOGIN_CHANNEL', 'errorlog_plain_webserver'),
],
];

View File

@@ -11,9 +11,7 @@
return [ return [
// Mail driver to use. // Mail driver to use.
// From Laravel 7+ this is MAIL_MAILER in laravel. // Options: smtp, mail, sendmail, log
// Kept as MAIL_DRIVER in BookStack to prevent breaking change.
// Options: smtp, sendmail, log, array
'driver' => env('MAIL_DRIVER', 'smtp'), 'driver' => env('MAIL_DRIVER', 'smtp'),
// SMTP host address // SMTP host address
@@ -25,7 +23,7 @@ return [
// Global "From" address & name // Global "From" address & name
'from' => [ 'from' => [
'address' => env('MAIL_FROM', 'mail@bookstackapp.com'), 'address' => env('MAIL_FROM', 'mail@bookstackapp.com'),
'name' => env('MAIL_FROM_NAME', 'BookStack'), 'name' => env('MAIL_FROM_NAME','BookStack')
], ],
// Email encryption protocol // Email encryption protocol
@@ -48,10 +46,4 @@ return [
], ],
], ],
// Log Channel
// If you are using the "log" driver, you may specify the logging channel
// if you prefer to keep mail messages separate from other log entries
// for simpler reading. Otherwise, the default channel will be used.
'log_channel' => env('MAIL_LOG_CHANNEL'),
]; ];

View File

@@ -1,35 +0,0 @@
<?php
return [
// Display name, shown to users, for OpenId option
'name' => env('OIDC_NAME', 'SSO'),
// Dump user details after a login request for debugging purposes
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
// Attribute, within a OpenId token, to find the user's display name
'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')),
// OAuth2/OpenId client id, as configured in your Authorization server.
'client_id' => env('OIDC_CLIENT_ID', null),
// OAuth2/OpenId client secret, as configured in your Authorization server.
'client_secret' => env('OIDC_CLIENT_SECRET', null),
// The issuer of the identity token (id_token) this will be compared with
// what is returned in the token.
'issuer' => env('OIDC_ISSUER', null),
// Auto-discover the relevant endpoints and keys from the issuer.
// Fetched details are cached for 15 minutes.
'discover' => env('OIDC_ISSUER_DISCOVER', false),
// Public key that's used to verify the JWT token with.
// Can be the key value itself or a local 'file://public.key' reference.
'jwt_public_key' => env('OIDC_PUBLIC_KEY', null),
// OAuth2 endpoints.
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
];

View File

@@ -11,8 +11,8 @@
return [ return [
// Default driver to use for the queue // Default driver to use for the queue
// Options: sync, database, redis // Options: null, sync, redis
'default' => env('QUEUE_CONNECTION', 'sync'), 'default' => env('QUEUE_DRIVER', 'sync'),
// Queue connection configuration // Queue connection configuration
'connections' => [ 'connections' => [
@@ -22,29 +22,48 @@ return [
], ],
'database' => [ 'database' => [
'driver' => 'database', 'driver' => 'database',
'table' => 'jobs', 'table' => 'jobs',
'queue' => 'default', 'queue' => 'default',
'retry_after' => 90, 'expire' => 60,
'after_commit' => false, ],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => 'localhost',
'queue' => 'default',
'ttr' => 60,
],
'sqs' => [
'driver' => 'sqs',
'key' => 'your-public-key',
'secret' => 'your-secret-key',
'queue' => 'your-queue-url',
'region' => 'us-east-1',
],
'iron' => [
'driver' => 'iron',
'host' => 'mq-aws-us-east-1.iron.io',
'token' => 'your-token',
'project' => 'your-project-id',
'queue' => 'your-queue-name',
'encrypt' => true,
], ],
'redis' => [ 'redis' => [
'driver' => 'redis', 'driver' => 'redis',
'connection' => 'default', 'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'), 'queue' => 'default',
'retry_after' => 90, 'expire' => 60,
'block_for' => null,
'after_commit' => false,
], ],
], ],
// Failed queue job logging // Failed queue job logging
'failed' => [ 'failed' => [
'driver' => 'database-uuids', 'database' => 'mysql', 'table' => 'failed_jobs',
'database' => 'mysql',
'table' => 'failed_jobs',
], ],
]; ];

View File

@@ -1,160 +0,0 @@
<?php
$SAML2_IDP_AUTHNCONTEXT = env('SAML2_IDP_AUTHNCONTEXT', true);
$SAML2_SP_x509 = env('SAML2_SP_x509', false);
return [
// Display name, shown to users, for SAML2 option
'name' => env('SAML2_NAME', 'SSO'),
// Dump user details after a login request for debugging purposes
'dump_user_details' => env('SAML2_DUMP_USER_DETAILS', false),
// Attribute, within a SAML response, to find the user's email address
'email_attribute' => env('SAML2_EMAIL_ATTRIBUTE', 'email'),
// Attribute, within a SAML response, to find the user's display name
'display_name_attributes' => explode('|', env('SAML2_DISPLAY_NAME_ATTRIBUTES', 'username')),
// Attribute, within a SAML response, to use to connect a BookStack user to the SAML user.
'external_id_attribute' => env('SAML2_EXTERNAL_ID_ATTRIBUTE', null),
// Group sync options
// Enable syncing, upon login, of SAML2 groups to BookStack groups
'user_to_groups' => env('SAML2_USER_TO_GROUPS', false),
// Attribute, within a SAML response, to find group names on
'group_attribute' => env('SAML2_GROUP_ATTRIBUTE', 'group'),
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
'remove_from_groups' => env('SAML2_REMOVE_FROM_GROUPS', false),
// Autoload IDP details from the metadata endpoint
'autoload_from_metadata' => env('SAML2_AUTOLOAD_METADATA', false),
// Overrides, in JSON format, to the configuration passed to underlying onelogin library.
'onelogin_overrides' => env('SAML2_ONELOGIN_OVERRIDES', null),
'onelogin' => [
// If 'strict' is True, then the PHP Toolkit will reject unsigned
// or unencrypted messages if it expects them signed or encrypted
// Also will reject the messages if not strictly follow the SAML
// standard: Destination, NameId, Conditions ... are validated too.
'strict' => true,
// Enable debug mode (to print errors)
'debug' => env('APP_DEBUG', false),
// Set a BaseURL to be used instead of try to guess
// the BaseURL of the view that process the SAML Message.
// Ex. http://sp.example.com/
// http://example.com/sp/
'baseurl' => null,
// Service Provider Data that we are deploying
'sp' => [
// Identifier of the SP entity (must be a URI)
'entityId' => '',
// Specifies info about where and how the <AuthnResponse> message MUST be
// returned to the requester, in this case our SP.
'assertionConsumerService' => [
// URL Location where the <Response> from the IdP will be returned
'url' => '',
// SAML protocol binding to be used when returning the <Response>
// message. Onelogin Toolkit supports for this endpoint the
// HTTP-POST binding only
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
],
// Specifies info about where and how the <Logout Response> message MUST be
// returned to the requester, in this case our SP.
'singleLogoutService' => [
// URL Location where the <Response> from the IdP will be returned
'url' => '',
// SAML protocol binding to be used when returning the <Response>
// message. Onelogin Toolkit supports for this endpoint the
// HTTP-Redirect binding only
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
],
// Specifies constraints on the name identifier to be used to
// represent the requested subject.
// Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported
'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
// Usually x509cert and privateKey of the SP are provided by files placed at
// the certs folder. But we can also provide them with the following parameters
'x509cert' => $SAML2_SP_x509 ?: '',
'privateKey' => env('SAML2_SP_x509_KEY', ''),
],
// Identity Provider Data that we want connect with our SP
'idp' => [
// Identifier of the IdP entity (must be a URI)
'entityId' => env('SAML2_IDP_ENTITYID', null),
// SSO endpoint info of the IdP. (Authentication Request protocol)
'singleSignOnService' => [
// URL Target of the IdP where the SP will send the Authentication Request Message
'url' => env('SAML2_IDP_SSO', null),
// SAML protocol binding to be used when returning the <Response>
// message. Onelogin Toolkit supports for this endpoint the
// HTTP-Redirect binding only
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
],
// SLO endpoint info of the IdP.
'singleLogoutService' => [
// URL Location of the IdP where the SP will send the SLO Request
'url' => env('SAML2_IDP_SLO', null),
// URL location of the IdP where the SP will send the SLO Response (ResponseLocation)
// if not set, url for the SLO Request will be used
'responseUrl' => null,
// SAML protocol binding to be used when returning the <Response>
// message. Onelogin Toolkit supports for this endpoint the
// HTTP-Redirect binding only
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
],
// Public x509 certificate of the IdP
'x509cert' => env('SAML2_IDP_x509', null),
/*
* Instead of use the whole x509cert you can use a fingerprint in
* order to validate the SAMLResponse, but we don't recommend to use
* that method on production since is exploitable by a collision
* attack.
* (openssl x509 -noout -fingerprint -in "idp.crt" to generate it,
* or add for example the -sha256 , -sha384 or -sha512 parameter)
*
* If a fingerprint is provided, then the certFingerprintAlgorithm is required in order to
* let the toolkit know which Algorithm was used. Possible values: sha1, sha256, sha384 or sha512
* 'sha1' is the default value.
*/
// 'certFingerprint' => '',
// 'certFingerprintAlgorithm' => 'sha1',
/* In some scenarios the IdP uses different certificates for
* signing/encryption, or is under key rollover phase and more
* than one certificate is published on IdP metadata.
* In order to handle that the toolkit offers that parameter.
* (when used, 'x509cert' and 'certFingerprint' values are
* ignored).
*/
// 'x509certMulti' => array(
// 'signing' => array(
// 0 => '<cert1-string>',
// ),
// 'encryption' => array(
// 0 => '<cert2-string>',
// )
// ),
],
'security' => [
// SAML2 Authn context
// When set to false no AuthContext will be sent in the AuthNRequest,
// When set to true (Default) you will get an AuthContext 'exact' 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'.
// Multiple forced values can be passed via a space separated array, For example:
// SAML2_IDP_AUTHNCONTEXT="urn:federation:authentication:windows urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
'requestedAuthnContext' => is_string($SAML2_IDP_AUTHNCONTEXT) ? explode(' ', $SAML2_IDP_AUTHNCONTEXT) : $SAML2_IDP_AUTHNCONTEXT,
// Sign requests and responses if a certificate is in use
'logoutRequestSigned' => (bool) $SAML2_SP_x509,
'logoutResponseSigned' => (bool) $SAML2_SP_x509,
'authnRequestsSigned' => (bool) $SAML2_SP_x509,
'lowercaseUrlencoding' => false,
],
],
];

View File

@@ -22,22 +22,39 @@ return [
// Callback URL for social authentication methods // Callback URL for social authentication methods
'callback_url' => env('APP_URL', false), 'callback_url' => env('APP_URL', false),
'mailgun' => [
'domain' => '',
'secret' => '',
],
'ses' => [
'key' => '',
'secret' => '',
'region' => 'us-east-1',
],
'stripe' => [
'model' => \BookStack\Auth\User::class,
'key' => '',
'secret' => '',
],
'github' => [ 'github' => [
'client_id' => env('GITHUB_APP_ID', false), 'client_id' => env('GITHUB_APP_ID', false),
'client_secret' => env('GITHUB_APP_SECRET', false), 'client_secret' => env('GITHUB_APP_SECRET', false),
'redirect' => env('APP_URL') . '/login/service/github/callback', 'redirect' => env('APP_URL') . '/login/service/github/callback',
'name' => 'GitHub', 'name' => 'GitHub',
'auto_register' => env('GITHUB_AUTO_REGISTER', false), 'auto_register' => env('GITHUB_AUTO_REGISTER', false),
'auto_confirm' => env('GITHUB_AUTO_CONFIRM_EMAIL', false), 'auto_confirm' => env('GITHUB_AUTO_CONFIRM_EMAIL', false),
], ],
'google' => [ 'google' => [
'client_id' => env('GOOGLE_APP_ID', false), 'client_id' => env('GOOGLE_APP_ID', false),
'client_secret' => env('GOOGLE_APP_SECRET', false), 'client_secret' => env('GOOGLE_APP_SECRET', false),
'redirect' => env('APP_URL') . '/login/service/google/callback', 'redirect' => env('APP_URL') . '/login/service/google/callback',
'name' => 'Google', 'name' => 'Google',
'auto_register' => env('GOOGLE_AUTO_REGISTER', false), 'auto_register' => env('GOOGLE_AUTO_REGISTER', false),
'auto_confirm' => env('GOOGLE_AUTO_CONFIRM_EMAIL', false), 'auto_confirm' => env('GOOGLE_AUTO_CONFIRM_EMAIL', false),
'select_account' => env('GOOGLE_SELECT_ACCOUNT', false), 'select_account' => env('GOOGLE_SELECT_ACCOUNT', false),
], ],
@@ -47,7 +64,7 @@ return [
'redirect' => env('APP_URL') . '/login/service/slack/callback', 'redirect' => env('APP_URL') . '/login/service/slack/callback',
'name' => 'Slack', 'name' => 'Slack',
'auto_register' => env('SLACK_AUTO_REGISTER', false), 'auto_register' => env('SLACK_AUTO_REGISTER', false),
'auto_confirm' => env('SLACK_AUTO_CONFIRM_EMAIL', false), 'auto_confirm' => env('SLACK_AUTO_CONFIRM_EMAIL', false),
], ],
'facebook' => [ 'facebook' => [
@@ -56,7 +73,7 @@ return [
'redirect' => env('APP_URL') . '/login/service/facebook/callback', 'redirect' => env('APP_URL') . '/login/service/facebook/callback',
'name' => 'Facebook', 'name' => 'Facebook',
'auto_register' => env('FACEBOOK_AUTO_REGISTER', false), 'auto_register' => env('FACEBOOK_AUTO_REGISTER', false),
'auto_confirm' => env('FACEBOOK_AUTO_CONFIRM_EMAIL', false), 'auto_confirm' => env('FACEBOOK_AUTO_CONFIRM_EMAIL', false),
], ],
'twitter' => [ 'twitter' => [
@@ -65,27 +82,27 @@ return [
'redirect' => env('APP_URL') . '/login/service/twitter/callback', 'redirect' => env('APP_URL') . '/login/service/twitter/callback',
'name' => 'Twitter', 'name' => 'Twitter',
'auto_register' => env('TWITTER_AUTO_REGISTER', false), 'auto_register' => env('TWITTER_AUTO_REGISTER', false),
'auto_confirm' => env('TWITTER_AUTO_CONFIRM_EMAIL', false), 'auto_confirm' => env('TWITTER_AUTO_CONFIRM_EMAIL', false),
], ],
'azure' => [ 'azure' => [
'client_id' => env('AZURE_APP_ID', false), 'client_id' => env('AZURE_APP_ID', false),
'client_secret' => env('AZURE_APP_SECRET', false), 'client_secret' => env('AZURE_APP_SECRET', false),
'tenant' => env('AZURE_TENANT', false), 'tenant' => env('AZURE_TENANT', false),
'redirect' => env('APP_URL') . '/login/service/azure/callback', 'redirect' => env('APP_URL') . '/login/service/azure/callback',
'name' => 'Microsoft Azure', 'name' => 'Microsoft Azure',
'auto_register' => env('AZURE_AUTO_REGISTER', false), 'auto_register' => env('AZURE_AUTO_REGISTER', false),
'auto_confirm' => env('AZURE_AUTO_CONFIRM_EMAIL', false), 'auto_confirm' => env('AZURE_AUTO_CONFIRM_EMAIL', false),
], ],
'okta' => [ 'okta' => [
'client_id' => env('OKTA_APP_ID'), 'client_id' => env('OKTA_APP_ID'),
'client_secret' => env('OKTA_APP_SECRET'), 'client_secret' => env('OKTA_APP_SECRET'),
'redirect' => env('APP_URL') . '/login/service/okta/callback', 'redirect' => env('APP_URL') . '/login/service/okta/callback',
'base_url' => env('OKTA_BASE_URL'), 'base_url' => env('OKTA_BASE_URL'),
'name' => 'Okta', 'name' => 'Okta',
'auto_register' => env('OKTA_AUTO_REGISTER', false), 'auto_register' => env('OKTA_AUTO_REGISTER', false),
'auto_confirm' => env('OKTA_AUTO_CONFIRM_EMAIL', false), 'auto_confirm' => env('OKTA_AUTO_CONFIRM_EMAIL', false),
], ],
'gitlab' => [ 'gitlab' => [
@@ -95,45 +112,41 @@ return [
'instance_uri' => env('GITLAB_BASE_URI'), // Needed only for self hosted instances 'instance_uri' => env('GITLAB_BASE_URI'), // Needed only for self hosted instances
'name' => 'GitLab', 'name' => 'GitLab',
'auto_register' => env('GITLAB_AUTO_REGISTER', false), 'auto_register' => env('GITLAB_AUTO_REGISTER', false),
'auto_confirm' => env('GITLAB_AUTO_CONFIRM_EMAIL', false), 'auto_confirm' => env('GITLAB_AUTO_CONFIRM_EMAIL', false),
], ],
'twitch' => [ 'twitch' => [
'client_id' => env('TWITCH_APP_ID'), 'client_id' => env('TWITCH_APP_ID'),
'client_secret' => env('TWITCH_APP_SECRET'), 'client_secret' => env('TWITCH_APP_SECRET'),
'redirect' => env('APP_URL') . '/login/service/twitch/callback', 'redirect' => env('APP_URL') . '/login/service/twitch/callback',
'name' => 'Twitch', 'name' => 'Twitch',
'auto_register' => env('TWITCH_AUTO_REGISTER', false), 'auto_register' => env('TWITCH_AUTO_REGISTER', false),
'auto_confirm' => env('TWITCH_AUTO_CONFIRM_EMAIL', false), 'auto_confirm' => env('TWITCH_AUTO_CONFIRM_EMAIL', false),
], ],
'discord' => [ 'discord' => [
'client_id' => env('DISCORD_APP_ID'), 'client_id' => env('DISCORD_APP_ID'),
'client_secret' => env('DISCORD_APP_SECRET'), 'client_secret' => env('DISCORD_APP_SECRET'),
'redirect' => env('APP_URL') . '/login/service/discord/callback', 'redirect' => env('APP_URL') . '/login/service/discord/callback',
'name' => 'Discord', 'name' => 'Discord',
'auto_register' => env('DISCORD_AUTO_REGISTER', false), 'auto_register' => env('DISCORD_AUTO_REGISTER', false),
'auto_confirm' => env('DISCORD_AUTO_CONFIRM_EMAIL', false), 'auto_confirm' => env('DISCORD_AUTO_CONFIRM_EMAIL', false),
], ],
'ldap' => [ 'ldap' => [
'server' => env('LDAP_SERVER', false), 'server' => env('LDAP_SERVER', false),
'dump_user_details' => env('LDAP_DUMP_USER_DETAILS', false), 'dn' => env('LDAP_DN', false),
'dn' => env('LDAP_DN', false), 'pass' => env('LDAP_PASS', false),
'pass' => env('LDAP_PASS', false), 'base_dn' => env('LDAP_BASE_DN', false),
'base_dn' => env('LDAP_BASE_DN', false), 'user_filter' => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
'user_filter' => env('LDAP_USER_FILTER', '(&(uid=${user}))'), 'version' => env('LDAP_VERSION', false),
'version' => env('LDAP_VERSION', false), 'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'),
'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'), 'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'),
'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false), 'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false),
'user_to_groups' => env('LDAP_USER_TO_GROUPS', false), 'user_to_groups' => env('LDAP_USER_TO_GROUPS',false),
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'), 'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false), 'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS',false),
'tls_insecure' => env('LDAP_TLS_INSECURE', false), 'tls_insecure' => env('LDAP_TLS_INSECURE', false),
'start_tls' => env('LDAP_START_TLS', false), ]
'thumbnail_attribute' => env('LDAP_THUMBNAIL_ATTRIBUTE', null),
],
]; ];

View File

@@ -1,7 +1,5 @@
<?php <?php
use Illuminate\Support\Str;
/** /**
* Session configuration options. * Session configuration options.
* *
@@ -37,18 +35,13 @@ return [
// Session database table, if database driver is in use // Session database table, if database driver is in use
'table' => 'sessions', 'table' => 'sessions',
// Session Cache Store
// When using the "apc" or "memcached" session drivers, you may specify a
// cache store that should be used for these sessions. This value must
// correspond with one of the application's configured cache stores.
'store' => null,
// Session Sweeping Lottery // Session Sweeping Lottery
// Some session drivers must manually sweep their storage location to get // Some session drivers must manually sweep their storage location to get
// rid of old sessions from storage. Here are the chances that it will // rid of old sessions from storage. Here are the chances that it will
// happen on a given request. By default, the odds are 2 out of 100. // happen on a given request. By default, the odds are 2 out of 100.
'lottery' => [2, 100], 'lottery' => [2, 100],
// Session Cookie Name // Session Cookie Name
// Here you may change the name of the cookie used to identify a session // Here you may change the name of the cookie used to identify a session
// instance by ID. The name specified here will get used every time a // instance by ID. The name specified here will get used every time a
@@ -59,7 +52,7 @@ return [
// The session cookie path determines the path for which the cookie will // The session cookie path determines the path for which the cookie will
// be regarded as available. Typically, this will be the root path of // be regarded as available. Typically, this will be the root path of
// your application but you are free to change this when necessary. // your application but you are free to change this when necessary.
'path' => '/' . (explode('/', env('APP_URL', ''), 4)[3] ?? ''), 'path' => '/',
// Session Cookie Domain // Session Cookie Domain
// Here you may change the domain of the cookie used to identify a session // Here you may change the domain of the cookie used to identify a session
@@ -71,8 +64,7 @@ return [
// By setting this option to true, session cookies will only be sent back // By setting this option to true, session cookies will only be sent back
// to the server if the browser has a HTTPS connection. This will keep // to the server if the browser has a HTTPS connection. This will keep
// the cookie from being sent to you if it can not be done securely. // the cookie from being sent to you if it can not be done securely.
'secure' => env('SESSION_SECURE_COOKIE', null) 'secure' => env('SESSION_SECURE_COOKIE', false),
?? Str::startsWith(env('APP_URL'), 'https:'),
// HTTP Access Only // HTTP Access Only
// Setting this value to true will prevent JavaScript from accessing the // Setting this value to true will prevent JavaScript from accessing the
@@ -83,6 +75,6 @@ return [
// This option determines how your cookies behave when cross-site requests // This option determines how your cookies behave when cross-site requests
// take place, and can be used to mitigate CSRF attacks. By default, we // take place, and can be used to mitigate CSRF attacks. By default, we
// do not enable this as other CSRF protection services are in place. // do not enable this as other CSRF protection services are in place.
// Options: lax, strict, none // Options: lax, strict
'same_site' => 'lax', 'same_site' => null,
]; ];

View File

@@ -16,20 +16,7 @@ return [
'app-editor' => 'wysiwyg', 'app-editor' => 'wysiwyg',
'app-color' => '#206ea7', 'app-color' => '#206ea7',
'app-color-light' => 'rgba(32,110,167,0.15)', 'app-color-light' => 'rgba(32,110,167,0.15)',
'bookshelf-color' => '#a94747',
'book-color' => '#077b70',
'chapter-color' => '#af4d0d',
'page-color' => '#206ea7',
'page-draft-color' => '#7e50b1',
'app-custom-head' => false, 'app-custom-head' => false,
'registration-enabled' => false, 'registration-enabled' => false,
// User-level default settings ];
'user' => [
'dark-mode-enabled' => env('APP_DEFAULT_DARK_MODE', false),
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
],
];

View File

@@ -13,9 +13,7 @@ return [
'enabled' => true, 'enabled' => true,
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false), 'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
'timeout' => false, 'timeout' => false,
'options' => [ 'options' => [],
'outline' => true,
],
'env' => [], 'env' => [],
], ],
'image' => [ 'image' => [

View File

@@ -14,8 +14,8 @@ class CleanupImages extends Command
* @var string * @var string
*/ */
protected $signature = 'bookstack:cleanup-images protected $signature = 'bookstack:cleanup-images
{--a|all : Also delete images that are only used in old revisions} {--a|all : Include images that are used in page revisions}
{--f|force : Actually run the deletions, Defaults to a dry-run} {--f|force : Actually run the deletions}
'; ';
/** /**
@@ -25,11 +25,11 @@ class CleanupImages extends Command
*/ */
protected $description = 'Cleanup images and drawings'; protected $description = 'Cleanup images and drawings';
protected $imageService; protected $imageService;
/** /**
* Create a new command instance. * Create a new command instance.
*
* @param \BookStack\Uploads\ImageService $imageService * @param \BookStack\Uploads\ImageService $imageService
*/ */
public function __construct(ImageService $imageService) public function __construct(ImageService $imageService)
@@ -63,7 +63,6 @@ class CleanupImages extends Command
$this->comment($deleteCount . ' images found that would have been deleted'); $this->comment($deleteCount . ' images found that would have been deleted');
$this->showDeletedImages($deleted); $this->showDeletedImages($deleted);
$this->comment('Run with -f or --force to perform deletions'); $this->comment('Run with -f or --force to perform deletions');
return; return;
} }

View File

@@ -2,7 +2,7 @@
namespace BookStack\Console\Commands; namespace BookStack\Console\Commands;
use BookStack\Entities\Models\PageRevision; use BookStack\Entities\PageRevision;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class ClearRevisions extends Command class ClearRevisions extends Command

View File

@@ -2,7 +2,6 @@
namespace BookStack\Console\Commands; namespace BookStack\Console\Commands;
use BookStack\Actions\View;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class ClearViews extends Command class ClearViews extends Command
@@ -19,10 +18,11 @@ class ClearViews extends Command
* *
* @var string * @var string
*/ */
protected $description = 'Clear all view-counts for all entities'; protected $description = 'Clear all view-counts for all entities.';
/** /**
* Create a new command instance. * Create a new command instance.
*
*/ */
public function __construct() public function __construct()
{ {
@@ -36,7 +36,7 @@ class ClearViews extends Command
*/ */
public function handle() public function handle()
{ {
View::clearAll(); \Views::resetAll();
$this->comment('Views cleared'); $this->comment('Views cleared');
} }
} }

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