mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 08:39:55 +03:00
Compare commits
6 Commits
v25.12.3
...
fix/video-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc275c0b53 | ||
|
|
8a2c13729e | ||
|
|
5f113f3f52 | ||
|
|
27954d6bc6 | ||
|
|
5bee25d651 | ||
|
|
0d1db98289 |
2
.browserslistrc
Normal file
2
.browserslistrc
Normal file
@@ -0,0 +1,2 @@
|
||||
>0.25%
|
||||
not op_mini all
|
||||
117
.env.example
117
.env.example
@@ -1,24 +1,11 @@
|
||||
# This file, when named as ".env" in the root of your BookStack install
|
||||
# folder, is used for the core configuration of the application.
|
||||
# By default this file contains the most common required options but
|
||||
# a full list of options can be found in the '.env.example.complete' file.
|
||||
|
||||
# NOTE: If any of your values contain a space or a hash you will need to
|
||||
# wrap the entire value in quotes. (eg. MAIL_FROM_NAME="BookStack Mailer")
|
||||
|
||||
# Application key
|
||||
# Used for encryption where needed.
|
||||
# Run `php artisan key:generate` to generate a valid key.
|
||||
# Environment
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_KEY=SomeRandomString
|
||||
|
||||
# Application URL
|
||||
# This must be the root URL that you want to host BookStack on.
|
||||
# All URLs in BookStack will be generated using this value
|
||||
# to ensure URLs generated are consistent and secure.
|
||||
# If you change this in the future you may need to run a command
|
||||
# to update stored URLs in the database. Command example:
|
||||
# php artisan bookstack:update-url https://old.example.com https://new.example.com
|
||||
APP_URL=https://example.com
|
||||
# The below url has to be set if using social auth options
|
||||
# or if you are not using BookStack at the root path of your domain.
|
||||
# APP_URL=http://bookstack.dev
|
||||
|
||||
# Database details
|
||||
DB_HOST=localhost
|
||||
@@ -26,28 +13,84 @@ DB_DATABASE=database_database
|
||||
DB_USERNAME=database_username
|
||||
DB_PASSWORD=database_user_password
|
||||
|
||||
# Storage system to use
|
||||
# By default files are stored on the local filesystem, with images being placed in
|
||||
# public web space so they can be efficiently served directly by the web-server.
|
||||
# For other options with different security levels & considerations, refer to:
|
||||
# https://www.bookstackapp.com/docs/admin/upload-config/
|
||||
# Cache and session
|
||||
CACHE_DRIVER=file
|
||||
SESSION_DRIVER=file
|
||||
# If using Memcached, comment the above and uncomment these
|
||||
#CACHE_DRIVER=memcached
|
||||
#SESSION_DRIVER=memcached
|
||||
QUEUE_DRIVER=sync
|
||||
# A different prefix is useful when multiple BookStack instances use the same caching server
|
||||
CACHE_PREFIX=bookstack
|
||||
|
||||
# Memcached settings
|
||||
# If using a UNIX socket path for the host, set the port to 0
|
||||
# This follows the following format: HOST:PORT:WEIGHT
|
||||
# For multiple servers separate with a comma
|
||||
MEMCACHED_SERVERS=127.0.0.1:11211:100
|
||||
|
||||
# Storage
|
||||
STORAGE_TYPE=local
|
||||
# Amazon S3 Config
|
||||
STORAGE_S3_KEY=false
|
||||
STORAGE_S3_SECRET=false
|
||||
STORAGE_S3_REGION=false
|
||||
STORAGE_S3_BUCKET=false
|
||||
# Storage URL
|
||||
# Used to prefix image urls for when using custom domains/cdns
|
||||
STORAGE_URL=false
|
||||
|
||||
# Mail system to use
|
||||
# Can be 'smtp' or 'sendmail'
|
||||
# General auth
|
||||
AUTH_METHOD=standard
|
||||
|
||||
# Social Authentication information. Defaults as off.
|
||||
GITHUB_APP_ID=false
|
||||
GITHUB_APP_SECRET=false
|
||||
GOOGLE_APP_ID=false
|
||||
GOOGLE_APP_SECRET=false
|
||||
GOOGLE_SELECT_ACCOUNT=false
|
||||
OKTA_BASE_URL=false
|
||||
OKTA_APP_ID=false
|
||||
OKTA_APP_SECRET=false
|
||||
TWITCH_APP_ID=false
|
||||
TWITCH_APP_SECRET=false
|
||||
GITLAB_APP_ID=false
|
||||
GITLAB_APP_SECRET=false
|
||||
GITLAB_BASE_URI=false
|
||||
DISCORD_APP_ID=false
|
||||
DISCORD_APP_SECRET=false
|
||||
|
||||
|
||||
# Disable default services such as Gravatar and Draw.IO
|
||||
DISABLE_EXTERNAL_SERVICES=false
|
||||
# Use custom avatar service, Sets fetch URL
|
||||
# Possible placeholders: ${hash} ${size} ${email}
|
||||
# If set, Avatars will be fetched regardless of DISABLE_EXTERNAL_SERVICES option.
|
||||
# AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon
|
||||
|
||||
# LDAP Settings
|
||||
LDAP_SERVER=false
|
||||
LDAP_BASE_DN=false
|
||||
LDAP_DN=false
|
||||
LDAP_PASS=false
|
||||
LDAP_USER_FILTER=false
|
||||
LDAP_VERSION=false
|
||||
# Do you want to sync LDAP groups to BookStack roles for a user
|
||||
LDAP_USER_TO_GROUPS=false
|
||||
# What is the LDAP attribute for group memberships
|
||||
LDAP_GROUP_ATTRIBUTE="memberOf"
|
||||
# Would you like to remove users from roles on BookStack if they do not match on LDAP
|
||||
# If false, the ldap groups-roles sync will only add users to roles
|
||||
LDAP_REMOVE_FROM_GROUPS=false
|
||||
# Set this option to disable LDAPS Certificate Verification
|
||||
LDAP_TLS_INSECURE=false
|
||||
|
||||
# Mail settings
|
||||
MAIL_DRIVER=smtp
|
||||
|
||||
# Mail sender details
|
||||
MAIL_FROM_NAME="BookStack"
|
||||
MAIL_FROM=bookstack@example.com
|
||||
|
||||
# SMTP mail options
|
||||
# These settings can be checked using the "Send a Test Email"
|
||||
# feature found in the "Settings > Maintenance" area of the system.
|
||||
# For more detailed documentation on mail options, refer to:
|
||||
# https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration
|
||||
MAIL_HOST=localhost
|
||||
MAIL_PORT=587
|
||||
MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM=null
|
||||
MAIL_FROM_NAME=null
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
# Full list of environment variables that can be used with BookStack.
|
||||
# Selectively copy these to your '.env' file as required.
|
||||
# Each option is shown with it's default value.
|
||||
# Do not copy this whole file to use as your '.env' file.
|
||||
|
||||
# The details here only serve as a quick reference.
|
||||
# Please refer to the BookStack documentation for full details:
|
||||
# https://www.bookstackapp.com/docs/
|
||||
|
||||
# Application environment
|
||||
# Can be 'production', 'development', 'testing' or 'demo'
|
||||
APP_ENV=production
|
||||
|
||||
# Enable debug mode
|
||||
# Shows advanced debug information and errors.
|
||||
# CAN EXPOSE OTHER VARIABLES, LEAVE DISABLED
|
||||
APP_DEBUG=false
|
||||
|
||||
# Application key
|
||||
# Used for encryption where needed.
|
||||
# Run `php artisan key:generate` to generate a valid key.
|
||||
APP_KEY=SomeRandomString
|
||||
|
||||
# Application URL
|
||||
# This must be the root URL that you want to host BookStack on.
|
||||
# All URL's in BookStack will be generated using this value.
|
||||
APP_URL=https://example.com
|
||||
|
||||
# Application default language
|
||||
# The default language choice to show.
|
||||
# May be overridden by user-preference or visitor browser settings.
|
||||
APP_LANG=en
|
||||
|
||||
# Auto-detect language for public visitors.
|
||||
# Uses browser-sent headers to infer a language.
|
||||
# APP_LANG will be used if such a header is not provided.
|
||||
APP_AUTO_LANG_PUBLIC=true
|
||||
|
||||
# Application timezones
|
||||
# The first option is used to determine what timezone is used for date storage.
|
||||
# Leaving that as "UTC" is advised.
|
||||
# The second option is used to set the timezone which will be used for date
|
||||
# formatting and display. This defaults to the "APP_TIMEZONE" value.
|
||||
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
|
||||
APP_TIMEZONE=UTC
|
||||
APP_DISPLAY_TIMEZONE=UTC
|
||||
|
||||
# Application theme
|
||||
# Used to specific a themes/<APP_THEME> folder where BookStack UI
|
||||
# overrides can be made. Defaults to disabled.
|
||||
APP_THEME=false
|
||||
|
||||
# Trusted proxies
|
||||
# Used to indicate trust of systems that proxy to the application so
|
||||
# certain header values (Such as "X-Forwarded-For") can be used from the
|
||||
# incoming proxy request to provide origin detail.
|
||||
# Set to an IP address, or multiple comma seperated IP addresses.
|
||||
# Can alternatively be set to "*" to trust all proxy addresses.
|
||||
APP_PROXIES=null
|
||||
|
||||
# Database details
|
||||
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
|
||||
# An ipv6 address can be used via the square bracket format ([::1]).
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=database_database
|
||||
DB_USERNAME=database_username
|
||||
DB_PASSWORD=database_user_password
|
||||
|
||||
# MySQL specific connection options
|
||||
# Path to Certificate Authority (CA) certificate file for your MySQL instance.
|
||||
# When this option is used host name identity verification will be performed
|
||||
# which checks the hostname, used by the client, against names within the
|
||||
# certificate itself (Common Name or Subject Alternative Name).
|
||||
MYSQL_ATTR_SSL_CA="/path/to/ca.pem"
|
||||
|
||||
# Mail configuration
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration
|
||||
MAIL_DRIVER=smtp
|
||||
MAIL_FROM=bookstack@example.com
|
||||
MAIL_FROM_NAME=BookStack
|
||||
|
||||
MAIL_HOST=localhost
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_VERIFY_SSL=true
|
||||
|
||||
MAIL_SENDMAIL_COMMAND="/usr/sbin/sendmail -bs"
|
||||
|
||||
# Cache & Session driver to use
|
||||
# Can be 'file', 'database', 'memcached' or 'redis'
|
||||
CACHE_DRIVER=file
|
||||
SESSION_DRIVER=file
|
||||
|
||||
# Session configuration
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_COOKIE_NAME=bookstack_session
|
||||
SESSION_SECURE_COOKIE=false
|
||||
|
||||
# Cache key prefix
|
||||
# Can be used to prevent conflicts multiple BookStack instances use the same store.
|
||||
CACHE_PREFIX=bookstack
|
||||
|
||||
# Memcached server configuration
|
||||
# If using a UNIX socket path for the host, set the port to 0
|
||||
# This follows the following format: HOST:PORT:WEIGHT
|
||||
# For multiple servers separate with a comma
|
||||
MEMCACHED_SERVERS=127.0.0.1:11211:100
|
||||
|
||||
# Redis server configuration
|
||||
# This follows the following format: HOST:PORT:DATABASE
|
||||
# or, if using a password: HOST:PORT:DATABASE:PASSWORD
|
||||
# For multiple servers separate with a comma. These will be clustered.
|
||||
REDIS_SERVERS=127.0.0.1:6379:0
|
||||
|
||||
# Queue driver to use
|
||||
# Can be 'sync', 'database' or 'redis'
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# Storage system to use
|
||||
# Can be 'local', 'local_secure' or 's3'
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# Image storage system to use
|
||||
# Defaults to the value of STORAGE_TYPE if unset.
|
||||
# Accepts the same values as STORAGE_TYPE.
|
||||
STORAGE_IMAGE_TYPE=local
|
||||
|
||||
# Attachment storage system to use
|
||||
# Defaults to the value of STORAGE_TYPE if unset.
|
||||
# Accepts the same values as STORAGE_TYPE although 'local' will be forced to 'local_secure'.
|
||||
STORAGE_ATTACHMENT_TYPE=local_secure
|
||||
|
||||
# Amazon S3 storage configuration
|
||||
STORAGE_S3_KEY=your-s3-key
|
||||
STORAGE_S3_SECRET=your-s3-secret
|
||||
STORAGE_S3_BUCKET=s3-bucket-name
|
||||
STORAGE_S3_REGION=s3-bucket-region
|
||||
|
||||
# S3 endpoint to use for storage calls
|
||||
# Only set this if using a non-Amazon s3-compatible service such as Minio
|
||||
STORAGE_S3_ENDPOINT=https://my-custom-s3-compatible.service.com:8001
|
||||
|
||||
# Storage URL prefix
|
||||
# Used as a base for any generated image urls.
|
||||
# An s3-format URL will be generated if not set.
|
||||
STORAGE_URL=false
|
||||
|
||||
# Authentication method to use
|
||||
# Can be 'standard', 'ldap', 'saml2' or 'oidc'
|
||||
AUTH_METHOD=standard
|
||||
|
||||
# Automatically initiate login via external auth system if it's the only auth method.
|
||||
# Works with saml2 or oidc auth methods.
|
||||
AUTH_AUTO_INITIATE=false
|
||||
|
||||
# Social authentication configuration
|
||||
# All disabled by default.
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/third-party-auth/
|
||||
|
||||
AZURE_APP_ID=false
|
||||
AZURE_APP_SECRET=false
|
||||
AZURE_TENANT=false
|
||||
AZURE_AUTO_REGISTER=false
|
||||
AZURE_AUTO_CONFIRM_EMAIL=false
|
||||
|
||||
DISCORD_APP_ID=false
|
||||
DISCORD_APP_SECRET=false
|
||||
DISCORD_AUTO_REGISTER=false
|
||||
DISCORD_AUTO_CONFIRM_EMAIL=false
|
||||
|
||||
FACEBOOK_APP_ID=false
|
||||
FACEBOOK_APP_SECRET=false
|
||||
FACEBOOK_AUTO_REGISTER=false
|
||||
FACEBOOK_AUTO_CONFIRM_EMAIL=false
|
||||
|
||||
GITHUB_APP_ID=false
|
||||
GITHUB_APP_SECRET=false
|
||||
GITHUB_AUTO_REGISTER=false
|
||||
GITHUB_AUTO_CONFIRM_EMAIL=false
|
||||
|
||||
GITLAB_APP_ID=false
|
||||
GITLAB_APP_SECRET=false
|
||||
GITLAB_BASE_URI=false
|
||||
GITLAB_AUTO_REGISTER=false
|
||||
GITLAB_AUTO_CONFIRM_EMAIL=false
|
||||
|
||||
GOOGLE_APP_ID=false
|
||||
GOOGLE_APP_SECRET=false
|
||||
GOOGLE_SELECT_ACCOUNT=false
|
||||
GOOGLE_AUTO_REGISTER=false
|
||||
GOOGLE_AUTO_CONFIRM_EMAIL=false
|
||||
|
||||
OKTA_BASE_URL=false
|
||||
OKTA_APP_ID=false
|
||||
OKTA_APP_SECRET=false
|
||||
OKTA_AUTO_REGISTER=false
|
||||
OKTA_AUTO_CONFIRM_EMAIL=false
|
||||
|
||||
SLACK_APP_ID=false
|
||||
SLACK_APP_SECRET=false
|
||||
SLACK_AUTO_REGISTER=false
|
||||
SLACK_AUTO_CONFIRM_EMAIL=false
|
||||
|
||||
TWITCH_APP_ID=false
|
||||
TWITCH_APP_SECRET=false
|
||||
TWITCH_AUTO_REGISTER=false
|
||||
TWITCH_AUTO_CONFIRM_EMAIL=false
|
||||
|
||||
TWITTER_APP_ID=false
|
||||
TWITTER_APP_SECRET=false
|
||||
TWITTER_AUTO_REGISTER=false
|
||||
TWITTER_AUTO_CONFIRM_EMAIL=false
|
||||
|
||||
# LDAP authentication configuration
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/ldap-auth/
|
||||
LDAP_SERVER=false
|
||||
LDAP_BASE_DN=false
|
||||
LDAP_DN=false
|
||||
LDAP_PASS=false
|
||||
LDAP_USER_FILTER="(&(uid={user}))"
|
||||
LDAP_VERSION=false
|
||||
LDAP_START_TLS=false
|
||||
LDAP_TLS_INSECURE=false
|
||||
LDAP_TLS_CA_CERT=false
|
||||
LDAP_ID_ATTRIBUTE=uid
|
||||
LDAP_EMAIL_ATTRIBUTE=mail
|
||||
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
|
||||
LDAP_THUMBNAIL_ATTRIBUTE=null
|
||||
LDAP_FOLLOW_REFERRALS=true
|
||||
LDAP_DUMP_USER_DETAILS=false
|
||||
|
||||
# LDAP group sync configuration
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/ldap-auth/
|
||||
LDAP_USER_TO_GROUPS=false
|
||||
LDAP_GROUP_ATTRIBUTE="memberOf"
|
||||
LDAP_REMOVE_FROM_GROUPS=false
|
||||
LDAP_DUMP_USER_GROUPS=false
|
||||
|
||||
# SAML authentication configuration
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
|
||||
SAML2_NAME=SSO
|
||||
SAML2_EMAIL_ATTRIBUTE=email
|
||||
SAML2_DISPLAY_NAME_ATTRIBUTES=username
|
||||
SAML2_EXTERNAL_ID_ATTRIBUTE=null
|
||||
SAML2_IDP_ENTITYID=null
|
||||
SAML2_IDP_SSO=null
|
||||
SAML2_IDP_SLO=null
|
||||
SAML2_IDP_x509=null
|
||||
SAML2_ONELOGIN_OVERRIDES=null
|
||||
SAML2_DUMP_USER_DETAILS=false
|
||||
SAML2_AUTOLOAD_METADATA=false
|
||||
SAML2_IDP_AUTHNCONTEXT=true
|
||||
SAML2_SP_x509=null
|
||||
SAML2_SP_x509_KEY=null
|
||||
|
||||
# SAML group sync configuration
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
|
||||
SAML2_USER_TO_GROUPS=false
|
||||
SAML2_GROUP_ATTRIBUTE=group
|
||||
SAML2_REMOVE_FROM_GROUPS=false
|
||||
|
||||
# OpenID Connect authentication configuration
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/oidc-auth/
|
||||
OIDC_NAME=SSO
|
||||
OIDC_DISPLAY_NAME_CLAIMS=name
|
||||
OIDC_CLIENT_ID=null
|
||||
OIDC_CLIENT_SECRET=null
|
||||
OIDC_ISSUER=null
|
||||
OIDC_ISSUER_DISCOVER=false
|
||||
OIDC_PUBLIC_KEY=null
|
||||
OIDC_AUTH_ENDPOINT=null
|
||||
OIDC_TOKEN_ENDPOINT=null
|
||||
OIDC_USERINFO_ENDPOINT=null
|
||||
OIDC_ADDITIONAL_SCOPES=null
|
||||
OIDC_DUMP_USER_DETAILS=false
|
||||
OIDC_USER_TO_GROUPS=false
|
||||
OIDC_GROUPS_CLAIM=groups
|
||||
OIDC_REMOVE_FROM_GROUPS=false
|
||||
OIDC_EXTERNAL_ID_CLAIM=sub
|
||||
OIDC_END_SESSION_ENDPOINT=false
|
||||
|
||||
# Disable default third-party services such as Gravatar and Draw.IO
|
||||
# Service-specific options will override this option
|
||||
DISABLE_EXTERNAL_SERVICES=false
|
||||
|
||||
# Use custom avatar service, Sets fetch URL
|
||||
# Possible placeholders: ${hash} ${size} ${email}
|
||||
# If set, Avatars will be fetched regardless of DISABLE_EXTERNAL_SERVICES option.
|
||||
# Example: AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon
|
||||
AVATAR_URL=
|
||||
|
||||
# Enable diagrams.net integration
|
||||
# Can simply be true/false to enable/disable the integration.
|
||||
# Alternatively, It can be URL to the diagrams.net instance you want to use.
|
||||
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1&configure=1
|
||||
DRAWIO=true
|
||||
|
||||
# Default item listing view
|
||||
# Used for public visitors and user's without a preference.
|
||||
# Can be 'list' or 'grid'.
|
||||
APP_VIEWS_BOOKS=list
|
||||
APP_VIEWS_BOOKSHELVES=grid
|
||||
APP_VIEWS_BOOKSHELF=grid
|
||||
|
||||
# Use dark mode by default
|
||||
# Will be overriden by any user/session preference.
|
||||
APP_DEFAULT_DARK_MODE=false
|
||||
|
||||
# Page revision limit
|
||||
# Number of page revisions to keep in the system before deleting old revisions.
|
||||
# If set to 'false' a limit will not be enforced.
|
||||
REVISION_LIMIT=100
|
||||
|
||||
# Recycle Bin Lifetime
|
||||
# The number of days that content will remain in the recycle bin before
|
||||
# being considered for auto-removal. It is not a guarantee that content will
|
||||
# be removed after this time.
|
||||
# Set to 0 for no recycle bin functionality.
|
||||
# Set to -1 for unlimited recycle bin lifetime.
|
||||
RECYCLE_BIN_LIFETIME=30
|
||||
|
||||
# File Upload Limit
|
||||
# Maximum file size, in megabytes, that can be uploaded to the system.
|
||||
FILE_UPLOAD_SIZE_LIMIT=50
|
||||
|
||||
# Export Page Size
|
||||
# Primarily used to determine page size of PDF exports.
|
||||
# Can be 'a4' or 'letter'.
|
||||
EXPORT_PAGE_SIZE=a4
|
||||
|
||||
# Export PDF Command
|
||||
# Set a command which can be used to convert a HTML file into a PDF file.
|
||||
# When false this will not be used.
|
||||
# String values represent the command to be called for conversion.
|
||||
# Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
|
||||
# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
|
||||
EXPORT_PDF_COMMAND=false
|
||||
|
||||
# Export PDF Command Timeout
|
||||
# The number of seconds that the export PDF command will run before a timeout occurs.
|
||||
# Only applies for the EXPORT_PDF_COMMAND option, not for DomPDF or wkhtmltopdf.
|
||||
EXPORT_PDF_COMMAND_TIMEOUT=15
|
||||
|
||||
# Set path to wkhtmltopdf binary for PDF generation.
|
||||
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
|
||||
# When false, BookStack will attempt to find a wkhtmltopdf in the application
|
||||
# root folder then fall back to the default dompdf renderer if no binary exists.
|
||||
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
||||
WKHTMLTOPDF=false
|
||||
|
||||
# Allow <script> tags in page content
|
||||
# Note, if set to 'true' the page editor may still escape scripts.
|
||||
ALLOW_CONTENT_SCRIPTS=false
|
||||
|
||||
# Indicate if robots/crawlers should crawl your instance.
|
||||
# Can be 'true', 'false' or 'null'.
|
||||
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting.
|
||||
# Contents of the robots.txt file can be overridden, making this option obsolete.
|
||||
ALLOW_ROBOTS=null
|
||||
|
||||
# Allow server-side fetches to be performed to potentially unknown
|
||||
# and user-provided locations. Primarily used in exports when loading
|
||||
# in externally referenced assets.
|
||||
# Can be 'true' or 'false'.
|
||||
ALLOW_UNTRUSTED_SERVER_FETCHING=false
|
||||
|
||||
# A list of hosts that BookStack can be iframed within.
|
||||
# Space separated if multiple. BookStack host domain is auto-inferred.
|
||||
# For Example: ALLOWED_IFRAME_HOSTS="https://example.com https://a.example.com"
|
||||
# Setting this option will also auto-adjust cookies to be SameSite=None.
|
||||
ALLOWED_IFRAME_HOSTS=null
|
||||
|
||||
# A list of sources/hostnames that can be loaded within iframes within BookStack.
|
||||
# Space separated if multiple. BookStack host domain is auto-inferred.
|
||||
# Can be set to a lone "*" to allow all sources for iframe content (Not advised).
|
||||
# Defaults to a set of common services.
|
||||
# Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
|
||||
ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com"
|
||||
|
||||
# A list of the sources/hostnames that can be reached by application SSR calls.
|
||||
# This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
|
||||
# Host-specific functionality (usually controlled via other options) like auth
|
||||
# or user avatars for example, won't use this list.
|
||||
# Space seperated if multiple. Can use '*' as a wildcard.
|
||||
# Values will be compared prefix-matched, case-insensitive, against called SSR urls.
|
||||
# Defaults to allow all hosts.
|
||||
ALLOWED_SSR_HOSTS="*"
|
||||
|
||||
# The default and maximum item-counts for listing API requests.
|
||||
API_DEFAULT_ITEM_COUNT=100
|
||||
API_MAX_ITEM_COUNT=500
|
||||
|
||||
# The number of API requests that can be made per minute by a single user.
|
||||
API_REQUESTS_PER_MIN=180
|
||||
|
||||
# Enable the logging of failed email+password logins with the given message.
|
||||
# The default log channel below uses the php 'error_log' function which commonly
|
||||
# results in messages being output to the webserver error logs.
|
||||
# The message can contain a %u parameter which will be replaced with the login
|
||||
# user identifier (Username or email).
|
||||
LOG_FAILED_LOGIN_MESSAGE=false
|
||||
LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
|
||||
|
||||
# Alter the precision of IP addresses stored by BookStack.
|
||||
# Should be a number between 0 and 4, where 4 retains the full IP address
|
||||
# and 0 completely hides the IP address. As an example, a value of 2 for the
|
||||
# IP address '146.191.42.4' would result in '146.191.x.x' being logged.
|
||||
# For the IPv6 address '2001:db8:85a3:8d3:1319:8a2e:370:7348' this would result as:
|
||||
# '2001:db8:85a3:8d3:x:x:x:x'
|
||||
IP_ADDRESS_PRECISION=4
|
||||
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,4 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [ssddanbrown]
|
||||
ko_fi: ssddanbrown
|
||||
25
.github/ISSUE_TEMPLATE/api_request.yml
vendored
25
.github/ISSUE_TEMPLATE/api_request.yml
vendored
@@ -1,25 +0,0 @@
|
||||
name: New API Endpoint or API Ability
|
||||
description: Request a new endpoint or API feature be added
|
||||
labels: [":nut_and_bolt: API Request"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: feature
|
||||
attributes:
|
||||
label: API Endpoint or Feature
|
||||
description: Clearly describe what you'd like to have added to the API.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: usecase
|
||||
attributes:
|
||||
label: Use-Case
|
||||
description: Explain the use-case that you're working-on that requires the above request.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Steps To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Your Configuration (please complete the following information):**
|
||||
- Exact BookStack Version (Found in settings):
|
||||
- PHP Version:
|
||||
- Hosting Method (Nginx/Apache/Docker):
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
61
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
61
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,61 +0,0 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us fix bugs & issues in existing supported functionality
|
||||
labels: [":bug: Bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out a bug report!
|
||||
Please note that this form is for reporting bugs in existing supported functionality.
|
||||
|
||||
If you are reporting something that's not an issue in functionality we've previously supported and/or is simply something different to your expectations, then it may be more appropriate to raise via a feature or support request instead.
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the Bug
|
||||
description: Provide a clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Detail the steps that would replicate this issue.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behaviour
|
||||
description: Provide clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Screenshots or Additional Context
|
||||
description: Provide any additional context and screenshots here to help us solve this issue.
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: browserdetails
|
||||
attributes:
|
||||
label: Browser Details
|
||||
description: |
|
||||
If this is an issue that occurs when using the BookStack interface, please provide details of the browser used which presents the reported issue.
|
||||
placeholder: (eg. Firefox 97 (64-bit) on Windows 11)
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: bsversion
|
||||
attributes:
|
||||
label: Exact BookStack Version
|
||||
description: This can be found in the settings view of BookStack. Please provide an exact version(s) you've tested on.
|
||||
placeholder: (eg. v23.06.7)
|
||||
validations:
|
||||
required: true
|
||||
13
.github/ISSUE_TEMPLATE/config.yml
vendored
13
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,13 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord Chat Support
|
||||
url: https://discord.gg/ztkBqR2
|
||||
about: Realtime support & chat with the BookStack community and the team.
|
||||
|
||||
- name: Debugging & Common Issues
|
||||
url: https://www.bookstackapp.com/docs/admin/debugging/
|
||||
about: Find details on how to debug issues and view common issues with their resolutions.
|
||||
|
||||
- name: Official Support Plans
|
||||
url: https://www.bookstackapp.com/support/
|
||||
about: View our official support plans that offer assured support for business.
|
||||
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
|
||||
---
|
||||
|
||||
**Describe the feature you'd like**
|
||||
A clear description of the feature you'd like implemented in BookStack.
|
||||
|
||||
**Describe the benefits this feature would bring to BookStack users**
|
||||
Explain the measurable benefits this feature would achieve.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
58
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
58
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,58 +0,0 @@
|
||||
name: Feature Request
|
||||
description: Request a new feature or idea to be added to BookStack
|
||||
labels: [":hammer: Feature Request"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the feature you'd like
|
||||
description: Provide a clear description of the feature you'd like implemented in BookStack
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: benefits
|
||||
attributes:
|
||||
label: Describe the benefits this would bring to existing BookStack users
|
||||
description: |
|
||||
Explain the measurable benefits this feature would achieve for existing BookStack users.
|
||||
These benefits should details outcomes in terms of what this request solves/achieves, and should not be specific to implementation.
|
||||
This helps us understand the core desired goal so that a variety of potential implementations could be explored.
|
||||
This field is important. Lack if input here may lead to early issue closure.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: already_achieved
|
||||
attributes:
|
||||
label: Can the goal of this request already be achieved via other means?
|
||||
description: |
|
||||
Yes/No. If yes, please describe how the requested approach fits in with the existing method.
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: confirm-search
|
||||
attributes:
|
||||
label: Have you searched for an existing open/closed issue?
|
||||
description: |
|
||||
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundamental benefit/goal of your request.
|
||||
options:
|
||||
- label: I have searched for existing issues and none cover my fundamental request
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: existing_usage
|
||||
attributes:
|
||||
label: How long have you been using BookStack?
|
||||
options:
|
||||
- Not using yet, just scoping
|
||||
- Under 3 months
|
||||
- 3 months to 1 year
|
||||
- 1 to 5 years
|
||||
- Over 5 years
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
31
.github/ISSUE_TEMPLATE/language_request.yml
vendored
31
.github/ISSUE_TEMPLATE/language_request.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: Language Request
|
||||
description: Request a new language to be added to Crowdin for you to translate
|
||||
labels: [":earth_africa: Translations"]
|
||||
assignees:
|
||||
- ssddanbrown
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for offering to help start a new translation for BookStack!
|
||||
- type: input
|
||||
id: language
|
||||
attributes:
|
||||
label: Language to Add
|
||||
description: What language (and region if applicable) are you offering to help add to BookStack?
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: confirm
|
||||
attributes:
|
||||
label: Confirmation of Intent
|
||||
description: |
|
||||
This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack).
|
||||
Please don't use this template to request a new language that you are not prepared to provide translations for.
|
||||
options:
|
||||
- label: I confirm I'm offering to help translate for this new language via Crowdin.
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
*__Note: New languages are added at specific points of the development process so it may be a small while before the requested language is added for translation.__*
|
||||
55
.github/ISSUE_TEMPLATE/support_request.yml
vendored
55
.github/ISSUE_TEMPLATE/support_request.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: Support Request
|
||||
description: Request support for a specific problem you have not been able to solve yourself
|
||||
labels: [":dog2: Support"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: useddocs
|
||||
attributes:
|
||||
label: Attempted Debugging
|
||||
description: |
|
||||
I have read the [BookStack debugging](https://www.bookstackapp.com/docs/admin/debugging/) page and seeked resolution or more
|
||||
detail for the issue.
|
||||
options:
|
||||
- label: I have read the debugging page
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: searchissue
|
||||
attributes:
|
||||
label: Searched GitHub Issues
|
||||
description: |
|
||||
I have searched for the issue and potential resolutions within the [project's GitHub issue list](https://github.com/BookStackApp/BookStack/issues)
|
||||
options:
|
||||
- label: I have searched GitHub for the issue.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: scenario
|
||||
attributes:
|
||||
label: Describe the Scenario
|
||||
description: Detail the problem that you're having or what you need support with.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: bsversion
|
||||
attributes:
|
||||
label: Exact BookStack Version
|
||||
description: This can be found in the settings view of BookStack. Please provide an exact version.
|
||||
placeholder: (eg. v23.06.7)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Log Content
|
||||
description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below.
|
||||
placeholder: Be sure to remove any confidential details in your logs
|
||||
render: text
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: hosting
|
||||
attributes:
|
||||
label: Hosting Environment
|
||||
description: Describe your hosting environment as much as possible including any proxies used (If applicable).
|
||||
placeholder: (eg. PHP8.1 on Ubuntu 22.04 VPS, installed using official installation script)
|
||||
validations:
|
||||
required: true
|
||||
9
.github/ISSUE_TEMPLATE/z_blank_request.yml
vendored
9
.github/ISSUE_TEMPLATE/z_blank_request.yml
vendored
@@ -1,9 +0,0 @@
|
||||
name: Blank Request (Maintainers Only)
|
||||
description: For maintainers only - Start a blank request
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "**This blank request option is only for existing official maintainers of the project!** Please instead use a different request option. If you use this your issue will be closed off."
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
27
.github/SECURITY.md
vendored
27
.github/SECURITY.md
vendored
@@ -1,27 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the [latest version](https://github.com/BookStackApp/BookStack/releases) of BookStack is supported.
|
||||
We generally don't support older versions of BookStack due to maintenance effort and
|
||||
since we aim to provide a fairly stable upgrade path for new versions.
|
||||
|
||||
## Security Notifications
|
||||
|
||||
If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates).
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you've found an issue that likely has no impact to existing users (For example, in a development-only branch)
|
||||
feel free to raise it via a standard GitHub bug report issue.
|
||||
|
||||
If the issue could have a security impact to BookStack instances,
|
||||
please directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown).
|
||||
You will need to log in to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
|
||||
Alternatively you can send a DM via Mastodon to [@danb@fosstodon.org](https://fosstodon.org/@danb).
|
||||
|
||||
Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability
|
||||
can often take a little time due to the amount of preparation required, to ensure the vulnerability has
|
||||
been covered, and to create the content required to adequately notify the user-base.
|
||||
|
||||
Thank you for keeping BookStack instances safe!
|
||||
532
.github/translators.txt
vendored
532
.github/translators.txt
vendored
@@ -1,532 +0,0 @@
|
||||
Name :: Languages
|
||||
@robertlandes :: German
|
||||
@SergioMendolia :: French
|
||||
@NakaharaL :: Portuguese, Brazilian
|
||||
@ReeseSebastian :: German
|
||||
@arietimmerman :: Dutch
|
||||
@diegoseso :: Spanish
|
||||
@S64 :: Japanese
|
||||
@JachuPL :: Polish
|
||||
@Joorem :: French
|
||||
@timoschwarzer :: German
|
||||
@sanderdw :: Dutch
|
||||
@lbguilherme :: Portuguese, Brazilian
|
||||
@marcusforsberg :: Swedish
|
||||
@artur-trzesiok :: Polish
|
||||
@Alwaysin :: French
|
||||
@msaus :: Japanese
|
||||
@moucho :: Spanish
|
||||
@vriic :: German
|
||||
@DeehSlash :: Portuguese, Brazilian
|
||||
@alex2702 :: German
|
||||
@nicobubulle :: French
|
||||
@kmoj86 :: Arabic
|
||||
@houbaron :: Chinese Traditional; Chinese Simplified
|
||||
@mullinsmikey :: Russian
|
||||
@limkukhyun :: Korean
|
||||
@CliffyPrime :: German
|
||||
@kejjang :: Chinese Traditional
|
||||
@TheLastOperator :: French
|
||||
@qianmengnet :: Simplified Chinese
|
||||
@ezzra :: German; German Informal
|
||||
@vasiliev123 :: Polish
|
||||
@Mant1kor :: Ukrainian
|
||||
@Xiphoseer :: German; German Informal
|
||||
@maantje :: Dutch
|
||||
@cima :: Czech
|
||||
@agvol :: Russian
|
||||
@Hambern :: Swedish
|
||||
@NootoNooto :: Dutch
|
||||
@kostefun :: Russian
|
||||
@lucaguindani :: French
|
||||
@miles75 :: Hungarian
|
||||
@danielroehrig-mm :: German
|
||||
@oykenfurkan :: Turkish
|
||||
@qligier :: French
|
||||
@johnroyer :: Traditional Chinese
|
||||
@artskoczylas :: Polish
|
||||
@dellamina :: Italian
|
||||
@jzoy :: Simplified Chinese
|
||||
@ististudio :: Korean
|
||||
@leomartinez :: Spanish Argentina
|
||||
@geins :: German
|
||||
@Ereza :: Catalan
|
||||
@benediktvolke :: German
|
||||
@Baptistou :: French
|
||||
@arcoai :: Spanish
|
||||
@Jokuna :: Korean
|
||||
@smartshogu :: German; German Informal
|
||||
@samadha56 :: Persian
|
||||
@mrmuminov :: Uzbek
|
||||
cipi1965 :: Italian
|
||||
Mykola Ronik (Mantikor) :: Ukrainian
|
||||
furkanoyk :: Turkish
|
||||
m0uch0 :: Spanish
|
||||
Maxim Zalata (zlatin) :: Russian; Ukrainian
|
||||
nutsflag :: French
|
||||
Leonardo Mario Martinez (leonardo.m.martinez) :: Spanish, Argentina
|
||||
Rodrigo Saczuk Niz (rodrigoniz) :: Portuguese, Brazilian
|
||||
叫钦叔就好 (254351722) :: Chinese Traditional; Chinese Simplified
|
||||
aekramer :: Dutch
|
||||
JachuPL :: Polish
|
||||
milesteg :: Hungarian
|
||||
Beenbag :: German; German Informal
|
||||
Lett3rs :: Danish
|
||||
Julian (julian.henneberg) :: German; German Informal
|
||||
3GNWn :: Danish
|
||||
dbguichu :: Chinese Simplified
|
||||
Randy Kim (hyunjun) :: Korean
|
||||
Francesco M. Taurino (ftaurino) :: Italian
|
||||
DanielFrederiksen :: Danish
|
||||
Finn Wessel (19finnwessel6) :: German Informal; German
|
||||
Gustav Kånåhols (Kurbitz) :: Swedish
|
||||
Vuong Trung Hieu (fpooon) :: Vietnamese
|
||||
Emil Petersen (emoyly) :: Danish
|
||||
mrjaboozy :: Slovenian
|
||||
Statium :: Russian
|
||||
Mikkel Struntze (MStruntze) :: Danish
|
||||
kostefun :: Russian
|
||||
Tuyen.NG (tuyendev) :: Vietnamese
|
||||
Ghost_chu (dbguichu) :: Chinese Simplified
|
||||
Ziipen :: Danish
|
||||
Samuel Schwarz (Guiph7quan) :: Czech
|
||||
Aleph (toishoki) :: Turkish
|
||||
Julio Alberto García (Yllelder) :: Spanish
|
||||
Rafael (raribeir) :: Portuguese, Brazilian
|
||||
Hiroyuki Odake (dakesan) :: Japanese
|
||||
Alex Lee (qianmengnet) :: Chinese Simplified
|
||||
swinn37 :: French
|
||||
Hasan Özbey (the-turk) :: Turkish
|
||||
rcy :: Swedish
|
||||
Ali Yasir Yılmaz (ayyilmaz) :: Turkish
|
||||
scureza :: Italian
|
||||
Biepa :: German Informal; German
|
||||
syecu :: Chinese Simplified
|
||||
Lap1t0r :: French
|
||||
Thinkverse (thinkverse) :: Swedish
|
||||
alef (toishoki) :: Turkish
|
||||
Robbert Feunekes (Muukuro) :: Dutch
|
||||
seohyeon.joo :: Korean
|
||||
Orenda (OREDNA) :: Bulgarian
|
||||
Marek Pavelka (marapavelka) :: Czech
|
||||
Venkinovec :: Czech
|
||||
Tommy Ku (tommyku) :: Chinese Traditional; Japanese
|
||||
Michał Bielejewski (bielej) :: Polish
|
||||
jozefrebjak :: Slovak
|
||||
Ikhwan Koo (Ikhwan.Koo) :: Korean
|
||||
Whay (remkovdhoef) :: Dutch
|
||||
jc7115 :: Chinese Traditional
|
||||
주서현 (seohyeon.joo) :: Korean
|
||||
ReadySystems :: Arabic
|
||||
HFinch :: German; German Informal
|
||||
brechtgijsens :: Dutch
|
||||
Lowkey (v587ygq) :: Chinese Simplified
|
||||
sdl-blue :: German Informal
|
||||
sqlik :: Polish
|
||||
Roy van Schaijk (royvanschaijk) :: Dutch
|
||||
Simsimpicpic :: French
|
||||
Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
|
||||
tatsuya.info :: Japanese
|
||||
fadiapp :: Arabic
|
||||
Jakub Bouček (jakubboucek) :: Czech
|
||||
Marco (cdrfun) :: German; German Informal
|
||||
10935336 :: Chinese Simplified
|
||||
孟繁阳 (FanyangMeng) :: Chinese Simplified
|
||||
Andrej Močan (andrejm) :: Slovenian
|
||||
gilane9_ :: Arabic
|
||||
Raed alnahdi (raednahdi) :: Arabic
|
||||
Xiphoseer :: German
|
||||
MerlinSVK (merlinsvk) :: Slovak
|
||||
Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
|
||||
MatthieuParis :: French
|
||||
Douradinho :: Portuguese, Brazilian; Portuguese
|
||||
Gaku Yaguchi (tama11) :: Japanese
|
||||
Zero Huang (johnroyer) :: Chinese Traditional
|
||||
jackaaa :: Chinese Traditional
|
||||
Irfan Hukama Arsyad (IrfanArsyad) :: Indonesian
|
||||
Jeff Huang (s8321414) :: Chinese Traditional
|
||||
Luís Tiago Favas (starkyller) :: Portuguese
|
||||
semirte :: Bosnian
|
||||
aarchijs :: Latvian
|
||||
Martins Pilsetnieks (pilsetnieks) :: Latvian
|
||||
Yonatan Magier (yonatanmgr) :: Hebrew
|
||||
FastHogi :: German Informal; German
|
||||
Ole Anders (Swoy) :: Norwegian Bokmal
|
||||
Atlochowski (atlochowski) :: Polish
|
||||
Simon (DefaultSimon) :: Slovenian
|
||||
Reinis Mednis (Mednis) :: Latvian
|
||||
toisho (toishoki) :: Turkish
|
||||
nikservik :: Ukrainian; Russian; Polish
|
||||
HenrijsS :: Latvian
|
||||
Pascal R-B (pborgner) :: German
|
||||
Boris (Ginfred) :: Russian
|
||||
Jonas Anker Rasmussen (jonasanker) :: Danish
|
||||
Gerwin de Keijzer (gdekeijzer) :: Dutch; German Informal; German
|
||||
kometchtech :: Japanese
|
||||
Auri (Atalonica) :: Catalan
|
||||
Francesco Franchina (ffranchina) :: Italian
|
||||
Aimrane Kds (aimrane.kds) :: Arabic
|
||||
whenwesober :: Indonesian
|
||||
Rem (remkovdhoef) :: Dutch
|
||||
syn7ax69 :: Bulgarian; Turkish; German
|
||||
Blaade :: French
|
||||
Behzad HosseinPoor (behzad.hp) :: Persian
|
||||
Ole Aldric (Swoy) :: Norwegian Bokmal
|
||||
fharis arabia (raednahdi) :: Arabic
|
||||
Alexander Predl (Harveyhase68) :: German
|
||||
Rem (Rem9000) :: Dutch
|
||||
Michał Stelmach (stelmach-web) :: Polish
|
||||
arniom :: French
|
||||
REMOVED_USER :: French; German; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
|
||||
林祖年 (contagion) :: Chinese Traditional
|
||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||
Nathanaël (nathanaelhoun) :: French
|
||||
A Ibnu Hibban (abd.ibnuhibban) :: Indonesian
|
||||
Frost-ZX :: Chinese Simplified
|
||||
Kuzma Simonov (ovmach) :: Russian
|
||||
Vojtěch Krystek (acantophis) :: Czech
|
||||
Michał Lipok (mLipok) :: Polish
|
||||
Nicolas Pawlak (Mikolajek) :: French; Polish; German
|
||||
Thomas Hansen (thomasdk81) :: Danish
|
||||
Hl2run :: Slovak
|
||||
Ngo Tri Hoai (trihoai) :: Vietnamese
|
||||
Atalonica :: Catalan
|
||||
慕容潭谈 (591442386) :: Chinese Simplified
|
||||
Radim Pesek (ramess18) :: Czech
|
||||
anastasiia.motylko :: Ukrainian
|
||||
Indrek Haav (IndrekHaav) :: Estonian
|
||||
na3shkw :: Japanese
|
||||
Giancarlo Di Massa (digitall-it) :: Italian
|
||||
M Nafis Al Mukhdi (mnafisalmukhdi1) :: Indonesian
|
||||
sulfo :: Danish
|
||||
Raukze :: German
|
||||
zygimantus :: Lithuanian
|
||||
marinkaberg :: Russian
|
||||
Vitaliy (gviabcua) :: Ukrainian
|
||||
mannycarreiro :: Portuguese
|
||||
Thiago Rafael Pereira de Carvalho (thiago.rafael) :: Portuguese, Brazilian
|
||||
Ken Roger Bolgnes (kenbo124) :: Norwegian Bokmal
|
||||
Nguyen Hung Phuong (hnwolf) :: Vietnamese
|
||||
Umut ERGENE (umutergene67) :: Turkish
|
||||
Tomáš Batelka (Vofy) :: Czech
|
||||
Mundo Racional (ismael.mesquita) :: Portuguese, Brazilian
|
||||
Zarik (3apuk) :: Russian
|
||||
Ali Shaatani (a.shaatani) :: Arabic
|
||||
ChacMaster :: Portuguese, Brazilian
|
||||
Saeed (saeed205) :: Persian
|
||||
Julesdevops :: French
|
||||
peter cerny (posli.to.semka) :: Slovak
|
||||
Pavel Karlin (pavelkarlin) :: Russian
|
||||
SmokingCrop :: Dutch
|
||||
Maciej Lebiest (Szwendacz) :: Polish
|
||||
DiscordDigital :: German; German Informal
|
||||
Gábor Marton (dodver) :: Hungarian
|
||||
Jakob Åsell (Jasell) :: Swedish
|
||||
Ghost_chu (ghostchu) :: Chinese Simplified
|
||||
Ravid Shachar (ravidshachar) :: Hebrew
|
||||
Helga Guchshenskaya (guchshenskaya) :: Russian
|
||||
daniel chou (chou0214) :: Chinese Traditional
|
||||
Manolis PATRIARCHE (m.patriarche) :: French
|
||||
Mohammed Haboubi (haboubi92) :: Arabic
|
||||
roncallyt :: Portuguese, Brazilian
|
||||
goegol :: Dutch
|
||||
msevgen :: Turkish
|
||||
Khroners :: French
|
||||
MASOUD HOSSEINY (masoudme) :: Persian
|
||||
Thomerson Roncally (roncallyt) :: Portuguese, Brazilian
|
||||
metaarch :: Bulgarian
|
||||
Xabi (xabikip) :: Basque
|
||||
pedromcsousa :: Portuguese
|
||||
Nir Louk (looknear) :: Hebrew
|
||||
Alex (qianmengnet) :: Chinese Simplified
|
||||
stothew :: German
|
||||
sgenc :: Turkish
|
||||
Shukrullo (vodiylik) :: Uzbek
|
||||
William W. (Nevnt) :: Chinese Traditional
|
||||
eamaro :: Portuguese
|
||||
Ypsilon-dev :: Arabic
|
||||
Hieu Vuong Trung (vuongtrunghieu) :: Vietnamese
|
||||
David Clubb (davidoclubb) :: Welsh
|
||||
welles freire (wellesximenes) :: Portuguese, Brazilian
|
||||
Magnus Jensen (MagnusHJensen) :: Danish
|
||||
Hesley Magno (hesleymagno) :: Portuguese, Brazilian
|
||||
Éric Gaspar (erga) :: French
|
||||
Fr3shlama :: German
|
||||
DSR :: Spanish, Argentina
|
||||
Andrii Bodnar (andrii-bodnar) :: Ukrainian
|
||||
Younes el Anjri (younesea28) :: Dutch
|
||||
Guclu Ozturk (gucluoz) :: Turkish
|
||||
Atmis :: French
|
||||
redjack666 :: Chinese Traditional
|
||||
Ashita007 :: Russian
|
||||
lihaorr :: Chinese Simplified
|
||||
Marcus Silber (marcus.silber82) :: German
|
||||
PellNet :: Croatian
|
||||
Winetradr :: German
|
||||
Sebastian Klaus (sebklaus) :: German
|
||||
Filip Antala (AntalaFilip) :: Slovak
|
||||
mcgong (GongMingCai) :: Chinese Simplified; Chinese Traditional
|
||||
Nanang Setia Budi (sefidananang) :: Indonesian
|
||||
Андрей Павлов (andrei.pavlov) :: Russian
|
||||
Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian
|
||||
Jihyeon Gim (PotatoGim) :: Korean
|
||||
Mihai Ochian (soulstorm19) :: Romanian
|
||||
HeartCore :: German Informal; German
|
||||
simon.pct :: French
|
||||
okaeiz :: Persian
|
||||
Naoto Ishikawa (na3shkw) :: Japanese
|
||||
sdhadi :: Persian
|
||||
DerLinkman (derlinkman) :: German; German Informal
|
||||
TurnArabic :: Arabic
|
||||
Martin Sebek (sebekmartin) :: Czech
|
||||
Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
|
||||
digilady :: Greek
|
||||
Linus (LinusOP) :: Swedish
|
||||
Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian
|
||||
RandomUser0815 :: German Informal; German
|
||||
Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
|
||||
구인회 (laskdjlaskdj12) :: Korean
|
||||
LiZerui (CNLiZerui) :: Chinese Traditional
|
||||
Fabrice Boyer (FabriceBoyer) :: French
|
||||
mikael (bitcanon) :: Swedish
|
||||
Matthias Mai (schnapsidee) :: German Informal; German
|
||||
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
|
||||
Jan Mitrof (jan.kachlik) :: Czech
|
||||
edwardsmirnov :: Russian
|
||||
Mr_OSS117 :: French
|
||||
shotu :: French
|
||||
Cesar_Lopez_Aguillon :: Spanish
|
||||
bdewoop :: German
|
||||
dina davoudi (dina.davoudi) :: Persian
|
||||
Angelos Chouvardas (achouvardas) :: Greek
|
||||
rndrss :: Portuguese, Brazilian
|
||||
rirac294 :: Russian
|
||||
David Furman (thefourCraft) :: Hebrew
|
||||
Pafzedog :: French
|
||||
Yllelder :: Spanish
|
||||
Adrian Ocneanu (aocneanu) :: Romanian
|
||||
Eduardo Castanho (EduardoCastanho) :: Portuguese
|
||||
VIET NAM VPS (vietnamvps) :: Vietnamese
|
||||
m4tthi4s :: French
|
||||
toras9000 :: Japanese
|
||||
pathab :: German
|
||||
MichelSchoon85 :: Dutch
|
||||
Jøran Haugli (haugli92) :: Norwegian Bokmal
|
||||
Vasileios Kouvelis (VasilisKouvelis) :: Greek
|
||||
Dremski :: Bulgarian
|
||||
Frédéric SENE (nothingfr) :: French
|
||||
bendem :: French
|
||||
kostasdizas :: Greek
|
||||
Ricardo Schroeder (brownstone666) :: Portuguese, Brazilian
|
||||
Eitan MG (EitanMG) :: Hebrew
|
||||
Robin Flikkema (RobinFlikkema) :: Dutch
|
||||
Michal Gurcik (mgurcik) :: Slovak
|
||||
Pooyan Arab (pooyanarab) :: Persian
|
||||
Ochi Darma Putra (troke12) :: Indonesian
|
||||
Hsin-Hsiang Peng (Hsins) :: Chinese Traditional
|
||||
Mosi Wang (mosiwang) :: Chinese Traditional
|
||||
骆言 (LawssssCat) :: Chinese Simplified
|
||||
Stickers Gaming Shøw (StickerSGSHOW) :: French
|
||||
Le Van Chinh (Chino) (lvanchinh86) :: Vietnamese
|
||||
Rubens nagios (rubenix) :: Catalan
|
||||
Patrick Dantas (pa-tiq) :: Portuguese, Brazilian
|
||||
Michal (michalgurcik) :: Slovak
|
||||
Nepomacs :: German
|
||||
Rubens (rubenix) :: Catalan
|
||||
m4z :: German; German Informal
|
||||
TheRazvy :: Romanian
|
||||
Yossi Zilber (lortens) :: Hebrew; Uzbek
|
||||
desdinova :: French
|
||||
Ingus Rūķis (ingus.rukis) :: Latvian
|
||||
Eugene Pershin (SilentEugene) :: Russian
|
||||
周盛道 (zhoushengdao) :: Chinese Simplified
|
||||
hamidreza amini (hamidrezaamini2022) :: Persian
|
||||
Tomislav Kraljević (tomislav.kraljevic) :: Croatian
|
||||
Taygun Yıldırım (yildirimtaygun) :: Turkish
|
||||
robing29 :: German
|
||||
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
|
||||
Igor V Belousov (biv) :: Russian
|
||||
David Bauer (davbauer) :: German; German Informal
|
||||
Guttorm Hveem (guttormhveem) :: Norwegian Nynorsk; Norwegian Bokmal
|
||||
Minh Giang Truong (minhgiang1204) :: Vietnamese
|
||||
Ioannis Ioannides (i.ioannides) :: Greek
|
||||
Vadim (vadrozh) :: Russian
|
||||
Flip333 :: German Informal; German
|
||||
Paulo Henrique (paulohsantos114) :: Portuguese, Brazilian
|
||||
Dženan (Dzenan) :: Swedish
|
||||
Péter Péli (peter.peli) :: Hungarian
|
||||
TWME :: Chinese Traditional
|
||||
Sascha (Man-in-Black) :: German; German Informal
|
||||
Mohammadreza Madadi (madadi.efl) :: Persian
|
||||
Konstantin (kkovacheli) :: Ukrainian; Russian
|
||||
link1183 :: French
|
||||
Renan (rfpe) :: Portuguese, Brazilian
|
||||
Lowkey (bbsweb) :: Chinese Simplified
|
||||
ZZnOB (zznobzz) :: Russian
|
||||
rupus :: Swedish
|
||||
developernecsys :: Norwegian Nynorsk
|
||||
xuan LI (xuanli233) :: Chinese Simplified
|
||||
LameeQS :: Latvian
|
||||
Sorin T. (trimbitassorin) :: Romanian
|
||||
poesty :: Chinese Simplified
|
||||
balmag :: Hungarian
|
||||
Antti-Jussi Nygård (ajnyga) :: Finnish
|
||||
Eduard Ereza Martínez (Ereza) :: Catalan
|
||||
Jabir Lang (amar.almrad) :: Arabic
|
||||
Jaroslav Kobližek (foretix) :: Czech; French
|
||||
Wiktor Adamczyk (adamczyk.wiktor) :: Polish
|
||||
Abdulmajeed Alshuaibi (4Majeed) :: Arabic
|
||||
NotSmartZakk :: Czech
|
||||
HyoungMin Lee (ddokkaebi) :: Korean
|
||||
Dasferco :: Chinese Simplified
|
||||
Marcus Teräs (mteras) :: Finnish
|
||||
Serkan Yardim (serkanzz) :: Turkish
|
||||
Y (cnsr) :: Ukrainian
|
||||
ZY ZV (vy0b0x) :: Chinese Simplified
|
||||
diegobenitez :: Spanish
|
||||
Marc Hagen (MarcHagen) :: Dutch
|
||||
Kasper Alsøe (zeonos) :: Danish
|
||||
sultani :: Persian
|
||||
renge :: Korean
|
||||
Tim (thegatesdev) :: Dutch; German Informal; French; Romanian; Catalan; Czech; Danish; German; Finnish; Hungarian; Italian; Japanese; Korean; Polish; Russian; Ukrainian; Chinese Simplified; Chinese Traditional; Portuguese, Brazilian; Persian; Spanish, Argentina; Croatian; Norwegian Nynorsk; Estonian; Uzbek; Norwegian Bokmal
|
||||
Irdi (irdiOL) :: Albanian
|
||||
KateBarber :: Welsh
|
||||
Twister (theuncles75) :: Hebrew
|
||||
algernon19 :: Hungarian
|
||||
Ivan Krstic (ikrstic) :: Serbian (Cyrillic)
|
||||
Show :: Russian
|
||||
xBahamut :: Portuguese, Brazilian
|
||||
Pavle Knežević (pavleknezzevic) :: Serbian (Cyrillic)
|
||||
Vanja Cvelbar (b100w11) :: Slovenian
|
||||
simonpct :: French
|
||||
Honza Nagy (honza.nagy) :: Czech
|
||||
asd20752 :: Norwegian Bokmal
|
||||
Jan Picka (polipones) :: Czech
|
||||
diogoalex991 :: Portuguese
|
||||
Ehsan Sadeghi (ehsansadeghi) :: Persian
|
||||
ka_picit :: Danish
|
||||
cracrayol :: French
|
||||
CapuaSC :: Dutch
|
||||
Guardian75 :: German Informal
|
||||
mr-kanister :: German
|
||||
Michele Bastianelli (makoblaster) :: Italian
|
||||
jespernissen :: Danish
|
||||
Andrey (avmaksimov) :: Russian
|
||||
Gonzalo Loyola (AlFcl) :: Spanish, Argentina; Spanish
|
||||
grobert63 :: French
|
||||
wusst. (Supporti) :: German
|
||||
MaximMaximS :: Czech
|
||||
damian-klima :: Slovak
|
||||
crow_ :: Latvian
|
||||
JocelynDelalande :: French
|
||||
Jan (JW-CH) :: German Informal
|
||||
Timo B (lommes) :: German Informal
|
||||
Erik Lundstedt (Erik.Lundstedt) :: Swedish
|
||||
yngams (younessmouhid) :: Arabic
|
||||
Ohadp :: Hebrew
|
||||
cbridi :: Portuguese, Brazilian
|
||||
nanangsb :: Indonesian
|
||||
Michal Melich (michalmelich) :: Czech
|
||||
David (david-prv) :: German; German Informal
|
||||
Larry (lahoje) :: Swedish
|
||||
Marcia dos Santos (marciab80) :: Portuguese
|
||||
Ricard López Torres (richilpez.torres) :: Catalan
|
||||
sarahalves7 :: Portuguese, Brazilian
|
||||
petr.husak :: Czech
|
||||
javadataherian :: Persian
|
||||
Ludo-code :: French
|
||||
hollsten :: Swedish
|
||||
Ngoc Lan Phung (lanpncz) :: Vietnamese
|
||||
Worive :: Catalan; French
|
||||
Илья Скаба (skabailya) :: Russian
|
||||
Irjan Olsen (Irch) :: Norwegian Bokmal
|
||||
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
|
||||
Red (RedVortex) :: Hebrew
|
||||
xgrug :: Chinese Simplified
|
||||
HrCalmar :: Danish
|
||||
Avishay Rapp (AvishayRapp) :: Hebrew
|
||||
matthias4217 :: French
|
||||
Berke BOYLU2 (berkeboylu2) :: Turkish
|
||||
etwas7B :: German
|
||||
Mohammed srhiri (m.sghiri20) :: Arabic
|
||||
YongMin Kim (kym0118) :: Korean
|
||||
Rivo Zängov (Eraser) :: Estonian
|
||||
Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian
|
||||
ИEØ_ΙΙØZ (NEO_IIOZ) :: Chinese Traditional
|
||||
madnjpn (madnjpn.) :: Georgian
|
||||
Ásgeir Shiny Ásgeirsson (AsgeirShiny) :: Icelandic
|
||||
Mohammad Aftab Uddin (chirohorit) :: Bengali
|
||||
Yannis Karlaftis (meliseus) :: Greek
|
||||
felixxx :: German Informal
|
||||
randi (randi65535) :: Korean
|
||||
test65428 :: Greek
|
||||
zeronell :: Chinese Simplified
|
||||
julien Vinber (julienVinber) :: French
|
||||
Hyunwoo Park (oksure) :: Korean
|
||||
aram.rafeq.7 (aramrafeq2) :: Kurdish
|
||||
Raphael Moreno (RaphaelMoreno) :: Portuguese, Brazilian
|
||||
yn (user99) :: Arabic
|
||||
Pavel Zlatarov (pzlatarov) :: Bulgarian
|
||||
ingelres :: French
|
||||
mabdullah :: Arabic
|
||||
Skrabák Csaba (kekcsi) :: Hungarian
|
||||
Evert Meulie (Evert) :: Norwegian Bokmal
|
||||
Jasper Backer (jasperb) :: Dutch
|
||||
Alexandar Cavdarovski (ace.200112) :: Swedish
|
||||
구닥다리TV (yjj8353) :: Korean
|
||||
Onur Oskay (o.oskay) :: Turkish
|
||||
Sébastien Merveille (SebastienMerv) :: French
|
||||
Maxim Kouznetsov (masya.work) :: Hebrew
|
||||
neodvisnost :: Slovenian
|
||||
Soubi Agatsuma (bisouya) :: Hebrew
|
||||
Ilya Shaulov (ishaulov) :: Russian
|
||||
Konstantin Bobkov (b.konstantv) :: Russian
|
||||
Ruben Sutter (rubensutter) :: German
|
||||
jellium :: French
|
||||
Qxlkdr :: Swedish
|
||||
Hari (muhhari) :: Indonesian
|
||||
仙君御 (xjy) :: Chinese Simplified
|
||||
TapioM :: Finnish
|
||||
lingb58 :: Chinese Traditional
|
||||
Angel Pandey (angel-pandey) :: Nepali
|
||||
Supriya Shrestha (supriyashrestha) :: Nepali
|
||||
gprabhat :: Nepali
|
||||
CellCat :: Chinese Simplified
|
||||
Al Desrahim (aldesrahim) :: Indonesian
|
||||
ahmad abbaspour (deshneh.dar.diss) :: Persian
|
||||
Erjon K. (ekr) :: Albanian
|
||||
LiZerui (iamzrli) :: Chinese Traditional
|
||||
Ticker (ticker.com) :: Hebrew
|
||||
CrazyComputer :: Chinese Simplified
|
||||
Firr (FirrV) :: Russian
|
||||
João Faro (FaroJoaoFaro) :: Portuguese
|
||||
Danilo dos Santos Barbosa (bozochegou) :: Portuguese, Brazilian
|
||||
Chris (furesoft) :: German
|
||||
Silvia Isern (eiendragon) :: Catalan
|
||||
Dennis Kron Pedersen (ahjdp) :: Danish
|
||||
iamwhoiamwhoami :: Swedish
|
||||
Grogui :: French
|
||||
MrCharlesIII :: Arabic
|
||||
David Olsen (dawin) :: Danish
|
||||
ltnzr :: French
|
||||
Frank Holler (holler.frank) :: German; German Informal
|
||||
Korab Arifi (korabidev) :: Albanian
|
||||
Petr Husák (petrhusak) :: Czech
|
||||
Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian
|
||||
Amr (amr3k) :: Arabic
|
||||
Tahsin Ahmed (tahsinahmed2012) :: Bengali
|
||||
bojan_che :: Serbian (Cyrillic)
|
||||
setiawan setiawan (culture.setiawan) :: Indonesian
|
||||
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
|
||||
Gabriel Silver (GabrielBSilver) :: Hebrew
|
||||
Tomas Darius Davainis (Tomasdd) :: Lithuanian
|
||||
CriedHero :: Chinese Simplified
|
||||
Henrik (henrik2105) :: Norwegian Bokmal
|
||||
FoW (fofwisdom) :: Korean
|
||||
serinf-lauza :: French
|
||||
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
|
||||
Shadluk Avan (quldosh) :: Uzbek
|
||||
Marci (MartonPoto) :: Hungarian
|
||||
Michał Sadurski (wheeskeey) :: Polish
|
||||
JanDziaslo :: Polish
|
||||
40
.github/workflows/analyse-php.yml
vendored
40
.github/workflows/analyse-php.yml
vendored
@@ -1,40 +0,0 @@
|
||||
name: analyse-php
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.php'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.3
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-8.3
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --ansi
|
||||
|
||||
- name: Run static analysis check
|
||||
run: composer check-static
|
||||
24
.github/workflows/lint-js.yml
vendored
24
.github/workflows/lint-js.yml
vendored
@@ -1,24 +0,0 @@
|
||||
name: lint-js
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.json'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install NPM deps
|
||||
run: npm ci
|
||||
|
||||
- name: Run formatting check
|
||||
run: npm run lint
|
||||
25
.github/workflows/lint-php.yml
vendored
25
.github/workflows/lint-php.yml
vendored
@@ -1,25 +0,0 @@
|
||||
name: lint-php
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.php'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.3
|
||||
tools: phpcs
|
||||
|
||||
- name: Run formatting check
|
||||
run: composer lint
|
||||
29
.github/workflows/test-js.yml
vendored
29
.github/workflows/test-js.yml
vendored
@@ -1,29 +0,0 @@
|
||||
name: test-js
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.ts'
|
||||
- '**.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.ts'
|
||||
- '**.json'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install NPM deps
|
||||
run: npm ci
|
||||
|
||||
- name: Run TypeScript type checking
|
||||
run: npm run ts:lint
|
||||
|
||||
- name: Run JavaScript tests
|
||||
run: npm run test
|
||||
65
.github/workflows/test-migrations.yml
vendored
65
.github/workflows/test-migrations.yml
vendored
@@ -1,65 +0,0 @@
|
||||
name: test-migrations
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
- 'composer.*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.php'
|
||||
- 'composer.*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.2', '8.3', '8.4', '8.5']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Start MySQL
|
||||
run: |
|
||||
sudo systemctl start mysql
|
||||
|
||||
- name: Create database & user
|
||||
run: |
|
||||
mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
|
||||
mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';"
|
||||
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
|
||||
mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --ansi
|
||||
|
||||
- name: Start migration test
|
||||
run: |
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
||||
|
||||
- name: Start migration:rollback test
|
||||
run: |
|
||||
php${{ matrix.php }} artisan migrate:rollback --force -n --database=mysql_testing
|
||||
|
||||
- name: Start migration rerun test
|
||||
run: |
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
||||
61
.github/workflows/test-php.yml
vendored
61
.github/workflows/test-php.yml
vendored
@@ -1,61 +0,0 @@
|
||||
name: test-php
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
- 'composer.*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.php'
|
||||
- 'composer.*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.2', '8.3', '8.4', '8.5']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Start Database
|
||||
run: |
|
||||
sudo systemctl start mysql
|
||||
|
||||
- name: Setup Database
|
||||
run: |
|
||||
mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
|
||||
mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';"
|
||||
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
|
||||
mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --ansi
|
||||
|
||||
- name: Migrate and seed the database
|
||||
run: |
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
||||
php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
|
||||
|
||||
- name: Run PHP tests
|
||||
run: php${{ matrix.php }} ./vendor/bin/phpunit
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -1,20 +1,16 @@
|
||||
/vendor
|
||||
/node_modules
|
||||
/.vscode
|
||||
/composer
|
||||
/coverage
|
||||
Homestead.yaml
|
||||
.env
|
||||
.idea
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/public/dist/*.map
|
||||
/public/dist
|
||||
/public/plugins
|
||||
/public/css/*.map
|
||||
/public/js/*.map
|
||||
/public/css
|
||||
/public/js
|
||||
/public/bower
|
||||
/public/build/
|
||||
/public/favicon.ico
|
||||
/storage/images
|
||||
_ide_helper.php
|
||||
/storage/debugbar
|
||||
@@ -24,12 +20,5 @@ yarn.lock
|
||||
nbproject
|
||||
.buildpath
|
||||
.project
|
||||
.nvmrc
|
||||
.settings/
|
||||
webpack-stats.json
|
||||
.phpunit.result.cache
|
||||
.DS_Store
|
||||
phpstan.neon
|
||||
esbuild-meta.json
|
||||
.phpactor.json
|
||||
/*.zip
|
||||
webpack-stats.json
|
||||
28
.travis.yml
Normal file
28
.travis.yml
Normal 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
|
||||
3
LICENSE
3
LICENSE
@@ -1,6 +1,7 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-2026, Dan Brown and the BookStack project contributors.
|
||||
Copyright (c) 2018 Dan Brown and the BookStack Project contributors
|
||||
https://github.com/BookStackApp/BookStack/graphs/contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\EmailConfirmationService;
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Exceptions\ConfirmationEmailException;
|
||||
use BookStack\Exceptions\UserTokenExpiredException;
|
||||
use BookStack\Exceptions\UserTokenNotFoundException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Users\UserRepo;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ConfirmEmailController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected EmailConfirmationService $emailConfirmationService,
|
||||
protected LoginService $loginService,
|
||||
protected UserRepo $userRepo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the page to tell the user to check their email
|
||||
* and confirm their address.
|
||||
*/
|
||||
public function show()
|
||||
{
|
||||
return view('auth.register-confirm');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a notice that a user's email address has not been confirmed,
|
||||
* along with the option to re-send the confirmation email.
|
||||
*/
|
||||
public function showAwaiting()
|
||||
{
|
||||
$user = $this->loginService->getLastLoginAttemptUser();
|
||||
if ($user === null) {
|
||||
$this->showErrorNotification(trans('errors.login_user_not_found'));
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
return view('auth.register-confirm-awaiting');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for a user to provide their positive confirmation of their email.
|
||||
*/
|
||||
public function showAcceptForm(string $token)
|
||||
{
|
||||
return view('auth.register-confirm-accept', ['token' => $token]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms an email via a token and logs the user into the system.
|
||||
*
|
||||
* @throws ConfirmationEmailException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function confirm(Request $request)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'token' => ['required', 'string']
|
||||
]);
|
||||
|
||||
$token = $validated['token'];
|
||||
|
||||
try {
|
||||
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
|
||||
} catch (UserTokenNotFoundException $exception) {
|
||||
$this->showErrorNotification(trans('errors.email_confirmation_invalid'));
|
||||
|
||||
return redirect('/register');
|
||||
} catch (UserTokenExpiredException $exception) {
|
||||
$user = $this->userRepo->getById($exception->userId);
|
||||
$this->emailConfirmationService->sendConfirmation($user);
|
||||
$this->showErrorNotification(trans('errors.email_confirmation_expired'));
|
||||
|
||||
return redirect('/register/confirm');
|
||||
}
|
||||
|
||||
$user = $this->userRepo->getById($userId);
|
||||
$user->email_confirmed = true;
|
||||
$user->save();
|
||||
|
||||
$this->emailConfirmationService->deleteByUser($user);
|
||||
$this->showSuccessNotification(trans('auth.email_confirm_success'));
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend the confirmation email.
|
||||
*/
|
||||
public function resend()
|
||||
{
|
||||
$user = $this->loginService->getLastLoginAttemptUser();
|
||||
if ($user === null) {
|
||||
$this->showErrorNotification(trans('errors.login_user_not_found'));
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->emailConfirmationService->sendConfirmation($user);
|
||||
} catch (ConfirmationEmailException $e) {
|
||||
$this->showErrorNotification($e->getMessage());
|
||||
|
||||
return redirect('/login');
|
||||
} catch (Exception $e) {
|
||||
$this->showErrorNotification(trans('auth.email_confirm_send_error'));
|
||||
|
||||
return redirect('/register/awaiting');
|
||||
}
|
||||
|
||||
$this->showSuccessNotification(trans('auth.email_confirm_resent'));
|
||||
|
||||
return redirect('/register/confirm');
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Sleep;
|
||||
|
||||
class ForgotPasswordController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('guest');
|
||||
$this->middleware('guard:standard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the form to request a password reset link.
|
||||
*/
|
||||
public function showLinkRequestForm()
|
||||
{
|
||||
return view('auth.passwords.email');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reset link to the given user.
|
||||
*/
|
||||
public function sendResetLinkEmail(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// Add random pause to the response to help avoid time-base sniffing
|
||||
// of valid resets via slower email send handling.
|
||||
Sleep::for(random_int(1000, 3000))->milliseconds();
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$response = Password::broker()->sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
if ($response === Password::RESET_LINK_SENT) {
|
||||
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email'));
|
||||
}
|
||||
|
||||
if (in_array($response, [Password::RESET_LINK_SENT, Password::INVALID_USER, Password::RESET_THROTTLED])) {
|
||||
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
|
||||
$this->showSuccessNotification($message);
|
||||
|
||||
return redirect('/password/email')->with('status', trans($response));
|
||||
}
|
||||
|
||||
// If an error was returned by the password broker, we will get this message
|
||||
// translated so we can notify a user of the problem. We'll redirect back
|
||||
// to where the users came from so they can attempt this process again.
|
||||
return redirect('/password/email')->withErrors(
|
||||
['email' => trans($response)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
trait HandlesPartialLogins
|
||||
{
|
||||
/**
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
protected function currentOrLastAttemptedUser(): User
|
||||
{
|
||||
$loginService = app()->make(LoginService::class);
|
||||
$user = auth()->user() ?? $loginService->getLastLoginAttemptUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new NotFoundException(trans('errors.login_user_not_found'));
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
use ThrottlesLogins;
|
||||
|
||||
public function __construct(
|
||||
protected SocialDriverManager $socialDriverManager,
|
||||
protected LoginService $loginService,
|
||||
) {
|
||||
$this->middleware('guest', ['only' => ['getLogin', 'login']]);
|
||||
$this->middleware('guard:standard,ldap', ['only' => ['login']]);
|
||||
$this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the application login form.
|
||||
*/
|
||||
public function getLogin(Request $request)
|
||||
{
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
$authMethod = config('auth.method');
|
||||
$preventInitiation = $request->get('prevent_auto_init') === 'true';
|
||||
|
||||
if ($request->has('email')) {
|
||||
session()->flashInput([
|
||||
'email' => $request->get('email'),
|
||||
'password' => (config('app.env') === 'demo') ? $request->get('password', '') : '',
|
||||
]);
|
||||
}
|
||||
|
||||
// Store the previous location for redirect after login
|
||||
$this->updateIntendedFromPrevious();
|
||||
|
||||
if (!$preventInitiation && $this->loginService->shouldAutoInitiate()) {
|
||||
return view('auth.login-initiate', [
|
||||
'authMethod' => $authMethod,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('auth.login', [
|
||||
'socialDrivers' => $socialDrivers,
|
||||
'authMethod' => $authMethod,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a login request to the application.
|
||||
*/
|
||||
public function login(Request $request)
|
||||
{
|
||||
$this->validateLogin($request);
|
||||
$username = $request->get($this->username());
|
||||
|
||||
// Check login throttling attempts to see if they've gone over the limit
|
||||
if ($this->hasTooManyLoginAttempts($request)) {
|
||||
Activity::logFailedLogin($username);
|
||||
return $this->sendLockoutResponse($request);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->attemptLogin($request)) {
|
||||
return $this->sendLoginResponse($request);
|
||||
}
|
||||
} catch (LoginAttemptException $exception) {
|
||||
Activity::logFailedLogin($username);
|
||||
|
||||
return $this->sendLoginAttemptExceptionResponse($exception, $request);
|
||||
}
|
||||
|
||||
// On unsuccessful login attempt, Increment login attempts for throttling and log failed login.
|
||||
$this->incrementLoginAttempts($request);
|
||||
Activity::logFailedLogin($username);
|
||||
|
||||
// Throw validation failure for failed login
|
||||
throw ValidationException::withMessages([
|
||||
$this->username() => [trans('auth.failed')],
|
||||
])->redirectTo('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user and perform subsequent redirect.
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
return redirect($this->loginService->logout());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected username input based upon the current auth method.
|
||||
*/
|
||||
protected function username(): string
|
||||
{
|
||||
return config('auth.method') === 'standard' ? 'email' : 'username';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the needed authorization credentials from the request.
|
||||
*/
|
||||
protected function credentials(Request $request): array
|
||||
{
|
||||
return $request->only('username', 'email', 'password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the response after the user was authenticated.
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
protected function sendLoginResponse(Request $request)
|
||||
{
|
||||
$request->session()->regenerate();
|
||||
$this->clearLoginAttempts($request);
|
||||
|
||||
return redirect()->intended('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to log the user into the application.
|
||||
*/
|
||||
protected function attemptLogin(Request $request): bool
|
||||
{
|
||||
return $this->loginService->attempt(
|
||||
$this->credentials($request),
|
||||
auth()->getDefaultDriver(),
|
||||
$request->filled('remember')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate the user login request.
|
||||
* @throws ValidationException
|
||||
*/
|
||||
protected function validateLogin(Request $request): void
|
||||
{
|
||||
$rules = ['password' => ['required', 'string']];
|
||||
$authMethod = config('auth.method');
|
||||
|
||||
if ($authMethod === 'standard') {
|
||||
$rules['email'] = ['required', 'email'];
|
||||
}
|
||||
|
||||
if ($authMethod === 'ldap') {
|
||||
$rules['username'] = ['required', 'string'];
|
||||
$rules['email'] = ['email'];
|
||||
}
|
||||
|
||||
$request->validate($rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a response when a login attempt exception occurs.
|
||||
*/
|
||||
protected function sendLoginAttemptExceptionResponse(LoginAttemptException $exception, Request $request)
|
||||
{
|
||||
if ($exception instanceof LoginAttemptEmailNeededException) {
|
||||
$request->flash();
|
||||
session()->flash('request-email', true);
|
||||
}
|
||||
|
||||
if ($message = $exception->getMessage()) {
|
||||
$this->showWarningNotification($message);
|
||||
}
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the intended URL location from their previous URL.
|
||||
* Ignores if not from the current app instance or if from certain
|
||||
* login or authentication routes.
|
||||
*/
|
||||
protected function updateIntendedFromPrevious(): void
|
||||
{
|
||||
// Store the previous location for redirect after login
|
||||
$previous = url()->previous('');
|
||||
$isPreviousFromInstance = str_starts_with($previous, url('/'));
|
||||
if (!$previous || !setting('app-public') || !$isPreviousFromInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ignorePrefixList = [
|
||||
'/login',
|
||||
'/mfa',
|
||||
];
|
||||
|
||||
foreach ($ignorePrefixList as $ignorePrefix) {
|
||||
if (str_starts_with($previous, url($ignorePrefix))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
redirect()->setIntendedUrl($previous);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Access\Mfa\BackupCodeService;
|
||||
use BookStack\Access\Mfa\MfaSession;
|
||||
use BookStack\Access\Mfa\MfaValue;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Http\Controller;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class MfaBackupCodesController extends Controller
|
||||
{
|
||||
use HandlesPartialLogins;
|
||||
|
||||
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-backup-codes';
|
||||
|
||||
/**
|
||||
* Show a view that generates and displays backup codes.
|
||||
*/
|
||||
public function generate(BackupCodeService $codeService)
|
||||
{
|
||||
$codes = $codeService->generateNewSet();
|
||||
session()->put(self::SETUP_SECRET_SESSION_KEY, encrypt($codes));
|
||||
|
||||
$downloadUrl = 'data:application/octet-stream;base64,' . base64_encode(implode("\n\n", $codes));
|
||||
|
||||
$this->setPageTitle(trans('auth.mfa_gen_backup_codes_title'));
|
||||
|
||||
return view('mfa.backup-codes-generate', [
|
||||
'codes' => $codes,
|
||||
'downloadUrl' => $downloadUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the setup of backup codes, storing them against the user.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function confirm()
|
||||
{
|
||||
if (!session()->has(self::SETUP_SECRET_SESSION_KEY)) {
|
||||
return response('No generated codes found in the session', 500);
|
||||
}
|
||||
|
||||
$codes = decrypt(session()->pull(self::SETUP_SECRET_SESSION_KEY));
|
||||
MfaValue::upsertWithValue($this->currentOrLastAttemptedUser(), MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
|
||||
|
||||
$this->logActivity(ActivityType::MFA_SETUP_METHOD, 'backup-codes');
|
||||
|
||||
if (!auth()->check()) {
|
||||
$this->showSuccessNotification(trans('auth.mfa_setup_login_notification'));
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
return redirect('/mfa/setup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the MFA method submission on check.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function verify(Request $request, BackupCodeService $codeService, MfaSession $mfaSession, LoginService $loginService)
|
||||
{
|
||||
$user = $this->currentOrLastAttemptedUser();
|
||||
$codes = MfaValue::getValueForUser($user, MfaValue::METHOD_BACKUP_CODES) ?? '[]';
|
||||
|
||||
$this->validate($request, [
|
||||
'code' => [
|
||||
'required', 'max:12', 'min:8',
|
||||
function ($attribute, $value, $fail) use ($codeService, $codes) {
|
||||
if (!$codeService->inputCodeExistsInSet($value, $codes)) {
|
||||
$fail(trans('validation.backup_codes'));
|
||||
}
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
$updatedCodes = $codeService->removeInputCodeFromSet($request->get('code'), $codes);
|
||||
MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, $updatedCodes);
|
||||
|
||||
$mfaSession->markVerifiedForUser($user);
|
||||
$loginService->reattemptLoginFor($user);
|
||||
|
||||
if ($codeService->countCodesInSet($updatedCodes) < 5) {
|
||||
$this->showWarningNotification(trans('auth.mfa_backup_codes_usage_limit_warning'));
|
||||
}
|
||||
|
||||
return redirect()->intended();
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\Mfa\MfaValue;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MfaController extends Controller
|
||||
{
|
||||
use HandlesPartialLogins;
|
||||
|
||||
/**
|
||||
* Show the view to setup MFA for the current user.
|
||||
*/
|
||||
public function setup()
|
||||
{
|
||||
$userMethods = $this->currentOrLastAttemptedUser()
|
||||
->mfaValues()
|
||||
->get(['id', 'method'])
|
||||
->groupBy('method');
|
||||
|
||||
$this->setPageTitle(trans('auth.mfa_setup'));
|
||||
|
||||
return view('mfa.setup', [
|
||||
'userMethods' => $userMethods,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an MFA method for the current user.
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function remove(string $method)
|
||||
{
|
||||
if (in_array($method, MfaValue::allMethods())) {
|
||||
$value = user()->mfaValues()->where('method', '=', $method)->first();
|
||||
if ($value) {
|
||||
$value->delete();
|
||||
$this->logActivity(ActivityType::MFA_REMOVE_METHOD, $method);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect('/mfa/setup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the page to start an MFA verification.
|
||||
*/
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$desiredMethod = $request->get('method');
|
||||
$userMethods = $this->currentOrLastAttemptedUser()
|
||||
->mfaValues()
|
||||
->get(['id', 'method'])
|
||||
->groupBy('method');
|
||||
|
||||
// Basic search for the default option for a user.
|
||||
// (Prioritises totp over backup codes)
|
||||
$method = $userMethods->has($desiredMethod) ? $desiredMethod : $userMethods->keys()->sort()->reverse()->first();
|
||||
$otherMethods = $userMethods->keys()->filter(function ($userMethod) use ($method) {
|
||||
return $method !== $userMethod;
|
||||
})->all();
|
||||
|
||||
return view('mfa.verify', [
|
||||
'userMethods' => $userMethods,
|
||||
'method' => $method,
|
||||
'otherMethods' => $otherMethods,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Access\Mfa\MfaSession;
|
||||
use BookStack\Access\Mfa\MfaValue;
|
||||
use BookStack\Access\Mfa\TotpService;
|
||||
use BookStack\Access\Mfa\TotpValidationRule;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class MfaTotpController extends Controller
|
||||
{
|
||||
use HandlesPartialLogins;
|
||||
|
||||
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret';
|
||||
|
||||
public function __construct(
|
||||
protected TotpService $totp
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a view that generates and displays a TOTP QR code.
|
||||
*/
|
||||
public function generate()
|
||||
{
|
||||
if (session()->has(static::SETUP_SECRET_SESSION_KEY)) {
|
||||
$totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY));
|
||||
} else {
|
||||
$totpSecret = $this->totp->generateSecret();
|
||||
session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
|
||||
}
|
||||
|
||||
$qrCodeUrl = $this->totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
|
||||
$svg = $this->totp->generateQrCodeSvg($qrCodeUrl);
|
||||
|
||||
$this->setPageTitle(trans('auth.mfa_gen_totp_title'));
|
||||
|
||||
return view('mfa.totp-generate', [
|
||||
'url' => $qrCodeUrl,
|
||||
'svg' => $svg,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the setup of TOTP and save the auth method secret
|
||||
* against the current user.
|
||||
*
|
||||
* @throws ValidationException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function confirm(Request $request)
|
||||
{
|
||||
$totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY));
|
||||
$this->validate($request, [
|
||||
'code' => [
|
||||
'required',
|
||||
'max:12', 'min:4',
|
||||
new TotpValidationRule($totpSecret, $this->totp),
|
||||
],
|
||||
]);
|
||||
|
||||
MfaValue::upsertWithValue($this->currentOrLastAttemptedUser(), MfaValue::METHOD_TOTP, $totpSecret);
|
||||
session()->remove(static::SETUP_SECRET_SESSION_KEY);
|
||||
$this->logActivity(ActivityType::MFA_SETUP_METHOD, 'totp');
|
||||
|
||||
if (!auth()->check()) {
|
||||
$this->showSuccessNotification(trans('auth.mfa_setup_login_notification'));
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
return redirect('/mfa/setup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the MFA method submission on check.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function verify(Request $request, LoginService $loginService, MfaSession $mfaSession)
|
||||
{
|
||||
$user = $this->currentOrLastAttemptedUser();
|
||||
$totpSecret = MfaValue::getValueForUser($user, MfaValue::METHOD_TOTP);
|
||||
|
||||
$this->validate($request, [
|
||||
'code' => [
|
||||
'required',
|
||||
'max:12', 'min:4',
|
||||
new TotpValidationRule($totpSecret, $this->totp),
|
||||
],
|
||||
]);
|
||||
|
||||
$mfaSession->markVerifiedForUser($user);
|
||||
$loginService->reattemptLoginFor($user);
|
||||
|
||||
return redirect()->intended();
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\Oidc\OidcException;
|
||||
use BookStack\Access\Oidc\OidcService;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class OidcController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected OidcService $oidcService
|
||||
) {
|
||||
$this->middleware('guard:oidc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the authorization login flow via OIDC.
|
||||
*/
|
||||
public function login()
|
||||
{
|
||||
try {
|
||||
$loginDetails = $this->oidcService->login();
|
||||
} catch (OidcException $exception) {
|
||||
$this->showErrorNotification($exception->getMessage());
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
session()->put('oidc_state', time() . ':' . $loginDetails['state']);
|
||||
|
||||
return redirect($loginDetails['url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization flow redirect callback.
|
||||
* Processes authorization response from the OIDC Authorization Server.
|
||||
*/
|
||||
public function callback(Request $request)
|
||||
{
|
||||
$responseState = $request->query('state');
|
||||
$splitState = explode(':', session()->pull('oidc_state', ':'), 2);
|
||||
if (count($splitState) !== 2) {
|
||||
$splitState = [null, null];
|
||||
}
|
||||
|
||||
[$storedStateTime, $storedState] = $splitState;
|
||||
$threeMinutesAgo = time() - 3 * 60;
|
||||
|
||||
if (!$storedState || $storedState !== $responseState || intval($storedStateTime) < $threeMinutesAgo) {
|
||||
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->oidcService->processAuthorizeResponse($request->query('code'));
|
||||
} catch (OidcException $oidcException) {
|
||||
$this->showErrorNotification($oidcException->getMessage());
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
return redirect()->intended();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out, then start the OIDC RP-initiated logout process.
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
return redirect($this->oidcService->logout());
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Access\RegistrationService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SocialDriverManager $socialDriverManager,
|
||||
protected RegistrationService $registrationService,
|
||||
protected LoginService $loginService
|
||||
) {
|
||||
$this->middleware('guest');
|
||||
$this->middleware('guard:standard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the application registration form.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
public function getRegister()
|
||||
{
|
||||
$this->registrationService->ensureRegistrationAllowed();
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
|
||||
return view('auth.register', [
|
||||
'socialDrivers' => $socialDrivers,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a registration request for the application.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
* @throws StoppedAuthenticationException
|
||||
*/
|
||||
public function postRegister(Request $request)
|
||||
{
|
||||
$this->registrationService->ensureRegistrationAllowed();
|
||||
$this->validator($request->all())->validate();
|
||||
$userData = $request->all();
|
||||
|
||||
try {
|
||||
$user = $this->registrationService->registerUser($userData);
|
||||
$this->loginService->login($user, auth()->getDefaultDriver());
|
||||
} catch (UserRegistrationException $exception) {
|
||||
if ($exception->getMessage()) {
|
||||
$this->showErrorNotification($exception->getMessage());
|
||||
}
|
||||
|
||||
return redirect($exception->redirectLocation);
|
||||
}
|
||||
|
||||
$this->showSuccessNotification(trans('auth.register_success'));
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a validator for an incoming registration request.
|
||||
*/
|
||||
protected function validator(array $data): ValidatorContract
|
||||
{
|
||||
return Validator::make($data, [
|
||||
'name' => ['required', 'min:2', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users'],
|
||||
'password' => ['required', Password::default()],
|
||||
// Basic honey for bots that must not be filled in
|
||||
'username' => ['prohibited'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
|
||||
class ResetPasswordController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected LoginService $loginService
|
||||
) {
|
||||
$this->middleware('guest');
|
||||
$this->middleware('guard:standard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the password reset view for the given token.
|
||||
* If no token is present, display the link request form.
|
||||
*/
|
||||
public function showResetForm(Request $request)
|
||||
{
|
||||
$token = $request->route()->parameter('token');
|
||||
|
||||
return view('auth.passwords.reset')->with(
|
||||
['token' => $token, 'email' => $request->email]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the given user's password.
|
||||
*/
|
||||
public function reset(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'token' => 'required',
|
||||
'email' => 'required|email',
|
||||
'password' => ['required', 'confirmed', PasswordRule::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
|
||||
$response = Password::broker()->reset($credentials, function (User $user, string $password) {
|
||||
$user->password = Hash::make($password);
|
||||
$user->setRememberToken(Str::random(60));
|
||||
$user->save();
|
||||
|
||||
$this->loginService->login($user, auth()->getDefaultDriver());
|
||||
});
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $response === Password::PASSWORD_RESET
|
||||
? $this->sendResetResponse()
|
||||
: $this->sendResetFailedResponse($request, $response, $request->get('token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the response for a successful password reset.
|
||||
*/
|
||||
protected function sendResetResponse(): RedirectResponse
|
||||
{
|
||||
$this->showSuccessNotification(trans('auth.reset_password_success'));
|
||||
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user());
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the response for a failed password reset.
|
||||
*/
|
||||
protected function sendResetFailedResponse(Request $request, string $response, string $token): RedirectResponse
|
||||
{
|
||||
// We show invalid users as invalid tokens as to not leak what
|
||||
// users may exist in the system.
|
||||
if ($response === Password::INVALID_USER) {
|
||||
$response = Password::INVALID_TOKEN;
|
||||
}
|
||||
|
||||
return redirect("/password/reset/{$token}")
|
||||
->withInput($request->only('email'))
|
||||
->withErrors(['email' => trans($response)]);
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\Saml2Service;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Saml2Controller extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected Saml2Service $samlService
|
||||
) {
|
||||
$this->middleware('guard:saml2');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the login flow via SAML2.
|
||||
*/
|
||||
public function login()
|
||||
{
|
||||
$loginDetails = $this->samlService->login();
|
||||
session()->flash('saml2_request_id', $loginDetails['id']);
|
||||
|
||||
return redirect($loginDetails['url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the logout flow via SAML2.
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
$user = user();
|
||||
if ($user->isGuest()) {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
$logoutDetails = $this->samlService->logout($user);
|
||||
|
||||
if ($logoutDetails['id']) {
|
||||
session()->flash('saml2_logout_request_id', $logoutDetails['id']);
|
||||
}
|
||||
|
||||
return redirect($logoutDetails['url']);
|
||||
}
|
||||
|
||||
/*
|
||||
* Get the metadata for this SAML2 service provider.
|
||||
*/
|
||||
public function metadata()
|
||||
{
|
||||
$metaData = $this->samlService->metadata();
|
||||
|
||||
return response()->make($metaData, 200, [
|
||||
'Content-Type' => 'text/xml',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single logout service.
|
||||
* Handle logout requests and responses.
|
||||
*/
|
||||
public function sls()
|
||||
{
|
||||
$requestId = session()->pull('saml2_logout_request_id', null);
|
||||
$redirect = $this->samlService->processSlsResponse($requestId);
|
||||
|
||||
return redirect($redirect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assertion Consumer Service start URL. Takes the SAMLResponse from the IDP.
|
||||
* Due to being an external POST request, we likely won't have context of the
|
||||
* current user session due to lax cookies. To work around this we store the
|
||||
* SAMLResponse data and redirect to the processAcs endpoint for the actual
|
||||
* processing of the request with proper context of the user session.
|
||||
*/
|
||||
public function startAcs(Request $request)
|
||||
{
|
||||
$samlResponse = $request->get('SAMLResponse', null);
|
||||
|
||||
if (empty($samlResponse)) {
|
||||
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
$acsId = Str::random(16);
|
||||
$cacheKey = 'saml2_acs:' . $acsId;
|
||||
cache()->set($cacheKey, encrypt($samlResponse), 10);
|
||||
|
||||
return redirect()->guest('/saml2/acs?id=' . $acsId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assertion Consumer Service process endpoint.
|
||||
* Processes the SAML response from the IDP with context of the current session.
|
||||
* Takes the SAML request from the cache, added by the startAcs method above.
|
||||
*/
|
||||
public function processAcs(Request $request)
|
||||
{
|
||||
$acsId = $request->get('id', null);
|
||||
$cacheKey = 'saml2_acs:' . $acsId;
|
||||
$samlResponse = null;
|
||||
|
||||
try {
|
||||
$samlResponse = decrypt(cache()->pull($cacheKey));
|
||||
} catch (\Exception $exception) {
|
||||
}
|
||||
$requestId = session()->pull('saml2_request_id', null);
|
||||
|
||||
if (empty($acsId) || empty($samlResponse)) {
|
||||
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
$user = $this->samlService->processAcsResponse($requestId, $samlResponse);
|
||||
if (is_null($user)) {
|
||||
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
return redirect()->intended();
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Access\RegistrationService;
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Exceptions\SocialDriverNotConfigured;
|
||||
use BookStack\Exceptions\SocialSignInAccountNotUsed;
|
||||
use BookStack\Exceptions\SocialSignInException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\User as SocialUser;
|
||||
|
||||
class SocialController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SocialAuthService $socialAuthService,
|
||||
protected RegistrationService $registrationService,
|
||||
protected LoginService $loginService,
|
||||
) {
|
||||
$this->middleware('guest')->only(['register']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to the relevant social site.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
*/
|
||||
public function login(string $socialDriver)
|
||||
{
|
||||
session()->put('social-callback', 'login');
|
||||
|
||||
return $this->socialAuthService->startLogIn($socialDriver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to the social site for authentication intended to register.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
public function register(string $socialDriver)
|
||||
{
|
||||
$this->registrationService->ensureRegistrationAllowed();
|
||||
session()->put('social-callback', 'register');
|
||||
|
||||
return $this->socialAuthService->startRegister($socialDriver);
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback for social login services.
|
||||
*
|
||||
* @throws SocialSignInException
|
||||
* @throws SocialDriverNotConfigured
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
public function callback(Request $request, string $socialDriver)
|
||||
{
|
||||
if (!session()->has('social-callback')) {
|
||||
throw new SocialSignInException(trans('errors.social_no_action_defined'), '/login');
|
||||
}
|
||||
|
||||
// Check request for error information
|
||||
if ($request->has('error') && $request->has('error_description')) {
|
||||
throw new SocialSignInException(trans('errors.social_login_bad_response', [
|
||||
'socialAccount' => $socialDriver,
|
||||
'error' => $request->get('error_description'),
|
||||
]), '/login');
|
||||
}
|
||||
|
||||
$action = session()->pull('social-callback');
|
||||
|
||||
// Attempt login or fall-back to register if allowed.
|
||||
$socialUser = $this->socialAuthService->getSocialUser($socialDriver);
|
||||
if ($action === 'login') {
|
||||
try {
|
||||
return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser);
|
||||
} catch (SocialSignInAccountNotUsed $exception) {
|
||||
if ($this->socialAuthService->drivers()->isAutoRegisterEnabled($socialDriver)) {
|
||||
return $this->socialRegisterCallback($socialDriver, $socialUser);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'register') {
|
||||
return $this->socialRegisterCallback($socialDriver, $socialUser);
|
||||
}
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach a social account from a user.
|
||||
*/
|
||||
public function detach(string $socialDriver)
|
||||
{
|
||||
$this->socialAuthService->detachSocialAccount($socialDriver);
|
||||
session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => Str::title($socialDriver)]));
|
||||
|
||||
return redirect('/my-account/auth#social-accounts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user after a registration callback.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
protected function socialRegisterCallback(string $socialDriver, SocialUser $socialUser)
|
||||
{
|
||||
$socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
|
||||
$socialAccount = $this->socialAuthService->newSocialAccount($socialDriver, $socialUser);
|
||||
$emailVerified = $this->socialAuthService->drivers()->isAutoConfirmEmailEnabled($socialDriver);
|
||||
|
||||
// Create an array of the user data to create a new user instance
|
||||
$userData = [
|
||||
'name' => $socialUser->getName(),
|
||||
'email' => $socialUser->getEmail(),
|
||||
'password' => Str::random(32),
|
||||
];
|
||||
|
||||
// Take name from email address if empty
|
||||
if (!$userData['name']) {
|
||||
$userData['name'] = explode('@', $userData['email'])[0];
|
||||
}
|
||||
|
||||
$user = $this->registrationService->registerUser($userData, $socialAccount, $emailVerified);
|
||||
$this->showSuccessNotification(trans('auth.register_success'));
|
||||
$this->loginService->login($user, $socialDriver);
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use Illuminate\Cache\RateLimiter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
trait ThrottlesLogins
|
||||
{
|
||||
/**
|
||||
* Determine if the user has too many failed login attempts.
|
||||
*/
|
||||
protected function hasTooManyLoginAttempts(Request $request): bool
|
||||
{
|
||||
return $this->limiter()->tooManyAttempts(
|
||||
$this->throttleKey($request),
|
||||
$this->maxAttempts()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the login attempts for the user.
|
||||
*/
|
||||
protected function incrementLoginAttempts(Request $request): void
|
||||
{
|
||||
$this->limiter()->hit(
|
||||
$this->throttleKey($request),
|
||||
$this->decayMinutes() * 60
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the user after determining they are locked out.
|
||||
* @throws ValidationException
|
||||
*/
|
||||
protected function sendLockoutResponse(Request $request): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$seconds = $this->limiter()->availableIn(
|
||||
$this->throttleKey($request)
|
||||
);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
$this->username() => [trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
])],
|
||||
])->status(Response::HTTP_TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the login locks for the given user credentials.
|
||||
*/
|
||||
protected function clearLoginAttempts(Request $request): void
|
||||
{
|
||||
$this->limiter()->clear($this->throttleKey($request));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the throttle key for the given request.
|
||||
*/
|
||||
protected function throttleKey(Request $request): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($request->input($this->username())) . '|' . $request->ip());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiter instance.
|
||||
*/
|
||||
protected function limiter(): RateLimiter
|
||||
{
|
||||
return app()->make(RateLimiter::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum number of attempts to allow.
|
||||
*/
|
||||
public function maxAttempts(): int
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of minutes to throttle for.
|
||||
*/
|
||||
public function decayMinutes(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\UserInviteService;
|
||||
use BookStack\Exceptions\UserTokenExpiredException;
|
||||
use BookStack\Exceptions\UserTokenNotFoundException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Users\UserRepo;
|
||||
use Exception;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class UserInviteController extends Controller
|
||||
{
|
||||
protected UserInviteService $inviteService;
|
||||
protected UserRepo $userRepo;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(UserInviteService $inviteService, UserRepo $userRepo)
|
||||
{
|
||||
$this->middleware('guest');
|
||||
$this->middleware('guard:standard');
|
||||
|
||||
$this->inviteService = $inviteService;
|
||||
$this->userRepo = $userRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the page for the user to set the password for their account.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function showSetPassword(string $token)
|
||||
{
|
||||
try {
|
||||
$this->inviteService->checkTokenAndGetUserId($token);
|
||||
} catch (Exception $exception) {
|
||||
return $this->handleTokenException($exception);
|
||||
}
|
||||
|
||||
return view('auth.invite-set-password', [
|
||||
'token' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the password for an invited user and then grants them access.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function setPassword(Request $request, string $token)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'password' => ['required', Password::default()],
|
||||
]);
|
||||
|
||||
try {
|
||||
$userId = $this->inviteService->checkTokenAndGetUserId($token);
|
||||
} catch (Exception $exception) {
|
||||
return $this->handleTokenException($exception);
|
||||
}
|
||||
|
||||
$user = $this->userRepo->getById($userId);
|
||||
$user->password = Hash::make($request->get('password'));
|
||||
$user->email_confirmed = true;
|
||||
$user->save();
|
||||
|
||||
$this->inviteService->deleteByUser($user);
|
||||
$this->showSuccessNotification(trans('auth.user_invite_success_login', ['appName' => setting('app-name')]));
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and validate the exception thrown when checking an invite token.
|
||||
*
|
||||
* @throws Exception
|
||||
*
|
||||
* @return RedirectResponse|Redirector
|
||||
*/
|
||||
protected function handleTokenException(Exception $exception)
|
||||
{
|
||||
if ($exception instanceof UserTokenNotFoundException) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
if ($exception instanceof UserTokenExpiredException) {
|
||||
$this->showErrorNotification(trans('errors.invite_token_expired'));
|
||||
|
||||
return redirect('/password/email');
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Access\Notifications\ConfirmEmailNotification;
|
||||
use BookStack\Exceptions\ConfirmationEmailException;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
class EmailConfirmationService extends UserTokenService
|
||||
{
|
||||
protected string $tokenTable = 'email_confirmations';
|
||||
protected int $expiryTime = 24;
|
||||
|
||||
/**
|
||||
* Create new confirmation for a user,
|
||||
* Also removes any existing old ones.
|
||||
*
|
||||
* @throws ConfirmationEmailException
|
||||
*/
|
||||
public function sendConfirmation(User $user): void
|
||||
{
|
||||
if ($user->email_confirmed) {
|
||||
throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login');
|
||||
}
|
||||
|
||||
$this->deleteByUser($user);
|
||||
$token = $this->createTokenForUser($user);
|
||||
|
||||
$user->notify(new ConfirmEmailNotification($token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if confirmation is required in this instance.
|
||||
*/
|
||||
public function confirmationRequired(): bool
|
||||
{
|
||||
return setting('registration-confirmation')
|
||||
|| setting('registration-restrict');
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\UserProvider;
|
||||
|
||||
class ExternalBaseUserProvider implements UserProvider
|
||||
{
|
||||
/**
|
||||
* Retrieve a user by their unique identifier.
|
||||
*/
|
||||
public function retrieveById(mixed $identifier): ?Authenticatable
|
||||
{
|
||||
return User::query()->find($identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user by their unique identifier and "remember me" token.
|
||||
*
|
||||
* @param string $token
|
||||
*/
|
||||
public function retrieveByToken(mixed $identifier, $token): null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the "remember me" token for the given user in storage.
|
||||
*
|
||||
* @param Authenticatable $user
|
||||
* @param string $token
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updateRememberToken(Authenticatable $user, $token)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user by the given credentials.
|
||||
*/
|
||||
public function retrieveByCredentials(array $credentials): ?Authenticatable
|
||||
{
|
||||
return User::query()
|
||||
->where('external_auth_id', $credentials['external_auth_id'])
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user against the given credentials.
|
||||
*/
|
||||
public function validateCredentials(Authenticatable $user, array $credentials): bool
|
||||
{
|
||||
// Should be done in the guard.
|
||||
return false;
|
||||
}
|
||||
|
||||
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
|
||||
{
|
||||
// No action to perform, any passwords are external in the auth system
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Users\Models\Role;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class GroupSyncService
|
||||
{
|
||||
/**
|
||||
* Check a role against an array of group names to see if it matches.
|
||||
* Checked against role 'external_auth_id' if set otherwise the name of the role.
|
||||
*/
|
||||
protected function roleMatchesGroupNames(Role $role, array $groupNames): bool
|
||||
{
|
||||
if ($role->external_auth_id) {
|
||||
return $this->externalIdMatchesGroupNames($role->external_auth_id, $groupNames);
|
||||
}
|
||||
|
||||
$roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
|
||||
|
||||
return in_array($roleName, $groupNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given external auth ID string matches one of the given group names.
|
||||
*/
|
||||
protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool
|
||||
{
|
||||
foreach ($this->parseRoleExternalAuthId($externalId) as $externalAuthId) {
|
||||
if (in_array($externalAuthId, $groupNames)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function parseRoleExternalAuthId(string $externalId): array
|
||||
{
|
||||
$inputIds = preg_split('/(?<!\\\),/', strtolower($externalId));
|
||||
$cleanIds = [];
|
||||
|
||||
foreach ($inputIds as $inputId) {
|
||||
$cleanIds[] = str_replace('\,', ',', trim($inputId));
|
||||
}
|
||||
|
||||
return $cleanIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an array of group names to BookStack system roles.
|
||||
* Formats group names to be lower-case and hyphenated.
|
||||
*/
|
||||
protected function matchGroupsToSystemsRoles(array $groupNames): Collection
|
||||
{
|
||||
foreach ($groupNames as $i => $groupName) {
|
||||
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
|
||||
}
|
||||
|
||||
$roles = Role::query()->get(['id', 'external_auth_id', 'display_name']);
|
||||
$matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
|
||||
return $this->roleMatchesGroupNames($role, $groupNames);
|
||||
});
|
||||
|
||||
return $matchedRoles->pluck('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the groups to the user roles for the current user.
|
||||
*/
|
||||
public function syncUserWithFoundGroups(User $user, array $userGroups, bool $detachExisting): void
|
||||
{
|
||||
// Get the ids for the roles from the names
|
||||
$groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups);
|
||||
|
||||
// Sync groups
|
||||
if ($detachExisting) {
|
||||
$user->roles()->sync($groupsAsRoles);
|
||||
$user->attachDefaultRole();
|
||||
} else {
|
||||
$user->roles()->syncWithoutDetaching($groupsAsRoles);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Guards;
|
||||
|
||||
/**
|
||||
* External Auth Session Guard.
|
||||
*
|
||||
* The login process for external auth (SAML2/OIDC) is async in nature, meaning it does not fit very well
|
||||
* into the default laravel 'Guard' auth flow. Instead, most of the logic is done via the relevant
|
||||
* controller and services. This class provides a safer, thin version of SessionGuard.
|
||||
*/
|
||||
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
|
||||
{
|
||||
/**
|
||||
* Validate a user's credentials.
|
||||
*/
|
||||
public function validate(array $credentials = []): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate a user using the given credentials.
|
||||
*
|
||||
* @param bool $remember
|
||||
*/
|
||||
public function attempt(array $credentials = [], $remember = false): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Guards;
|
||||
|
||||
use BookStack\Access\RegistrationService;
|
||||
use Illuminate\Auth\GuardHelpers;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
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.
|
||||
*/
|
||||
protected readonly string $name;
|
||||
|
||||
/**
|
||||
* The user we last attempted to retrieve.
|
||||
*/
|
||||
protected Authenticatable|null $lastAttempted;
|
||||
|
||||
/**
|
||||
* The session used by the guard.
|
||||
*/
|
||||
protected Session $session;
|
||||
|
||||
/**
|
||||
* Indicates if the logout method has been called.
|
||||
*/
|
||||
protected bool $loggedOut = false;
|
||||
|
||||
/**
|
||||
* Service to handle common registration actions.
|
||||
*/
|
||||
protected RegistrationService $registrationService;
|
||||
|
||||
/**
|
||||
* Create a new authentication guard.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
public function user(): Authenticatable|null
|
||||
{
|
||||
if ($this->loggedOut) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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.
|
||||
*/
|
||||
public function id(): int|null
|
||||
{
|
||||
if ($this->loggedOut) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->user()
|
||||
? $this->user()->getAuthIdentifier()
|
||||
: $this->session->get($this->getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a user into the application without sessions or cookies.
|
||||
*/
|
||||
public function once(array $credentials = []): bool
|
||||
{
|
||||
if ($this->validate($credentials)) {
|
||||
$this->setUser($this->lastAttempted);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the given user ID into the application without sessions or cookies.
|
||||
*/
|
||||
public function onceUsingId($id): Authenticatable|false
|
||||
{
|
||||
if (!is_null($user = $this->provider->retrieveById($id))) {
|
||||
$this->setUser($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user's credentials.
|
||||
*/
|
||||
public function validate(array $credentials = []): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate a user using the given credentials.
|
||||
* @param bool $remember
|
||||
*/
|
||||
public function attempt(array $credentials = [], $remember = false): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the given user ID into the application.
|
||||
* @param bool $remember
|
||||
*/
|
||||
public function loginUsingId(mixed $id, $remember = false): Authenticatable|false
|
||||
{
|
||||
// Always return false as to disable this method,
|
||||
// Logins should route through LoginService.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a user into the application.
|
||||
*
|
||||
* @param bool $remember
|
||||
*/
|
||||
public function login(Authenticatable $user, $remember = false): void
|
||||
{
|
||||
$this->updateSession($user->getAuthIdentifier());
|
||||
|
||||
$this->setUser($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the session with the given ID.
|
||||
*/
|
||||
protected function updateSession(string|int $id): void
|
||||
{
|
||||
$this->session->put($this->getName(), $id);
|
||||
|
||||
$this->session->migrate(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out of the application.
|
||||
*/
|
||||
public function logout(): void
|
||||
{
|
||||
$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.
|
||||
*/
|
||||
protected function clearUserDataFromStorage(): void
|
||||
{
|
||||
$this->session->remove($this->getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last user we attempted to authenticate.
|
||||
*/
|
||||
public function getLastAttempted(): Authenticatable
|
||||
{
|
||||
return $this->lastAttempted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a unique identifier for the auth session value.
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return 'login_' . $this->name . '_' . sha1(static::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user was authenticated via "remember me" cookie.
|
||||
*/
|
||||
public function viaRemember(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the currently cached user.
|
||||
*/
|
||||
public function getUser(): Authenticatable|null
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current user.
|
||||
*/
|
||||
public function setUser(Authenticatable $user): self
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
$this->loggedOut = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Guards;
|
||||
|
||||
use BookStack\Access\LdapService;
|
||||
use BookStack\Access\RegistrationService;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\LdapException;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Contracts\Auth\UserProvider;
|
||||
use Illuminate\Contracts\Session\Session;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
{
|
||||
protected LdapService $ldapService;
|
||||
|
||||
/**
|
||||
* LdapSessionGuard constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
$name,
|
||||
UserProvider $provider,
|
||||
Session $session,
|
||||
LdapService $ldapService,
|
||||
RegistrationService $registrationService
|
||||
) {
|
||||
$this->ldapService = $ldapService;
|
||||
parent::__construct($name, $provider, $session, $registrationService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user's credentials.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
public function validate(array $credentials = []): bool
|
||||
{
|
||||
$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 bool $remember
|
||||
*
|
||||
* @throws LdapException
|
||||
* @throws LoginAttemptException
|
||||
* @throws JsonDebugException
|
||||
*/
|
||||
public function attempt(array $credentials = [], $remember = false): bool
|
||||
{
|
||||
$username = $credentials['username'];
|
||||
$userDetails = $this->ldapService->getUserDetails($username);
|
||||
|
||||
$user = null;
|
||||
if (isset($userDetails['uid'])) {
|
||||
$this->lastAttempted = $user = $this->provider->retrieveByCredentials([
|
||||
'external_auth_id' => $userDetails['uid'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$this->ldapService->validateUserCredentials($userDetails, $credentials['password'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_null($user)) {
|
||||
try {
|
||||
$user = $this->createNewFromLdapAndCreds($userDetails, $credentials);
|
||||
} catch (UserRegistrationException $exception) {
|
||||
throw new LoginAttemptException($exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Sync LDAP groups if required
|
||||
if ($this->ldapService->shouldSyncGroups()) {
|
||||
$this->ldapService->syncGroups($user, $username);
|
||||
}
|
||||
|
||||
// Attach avatar if non-existent
|
||||
if (!$user->avatar()->exists()) {
|
||||
$this->ldapService->saveAndAttachAvatar($user, $userDetails);
|
||||
}
|
||||
|
||||
$this->login($user, $remember);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user from the given ldap credentials and login credentials.
|
||||
*
|
||||
* @throws LoginAttemptEmailNeededException
|
||||
* @throws LoginAttemptException
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
protected function createNewFromLdapAndCreds(array $ldapUserDetails, array $credentials): User
|
||||
{
|
||||
$email = trim($ldapUserDetails['email'] ?: ($credentials['email'] ?? ''));
|
||||
|
||||
if (empty($email)) {
|
||||
throw new LoginAttemptEmailNeededException();
|
||||
}
|
||||
|
||||
$details = [
|
||||
'name' => $ldapUserDetails['name'],
|
||||
'email' => $ldapUserDetails['email'] ?: $credentials['email'],
|
||||
'external_auth_id' => $ldapUserDetails['uid'],
|
||||
'password' => Str::random(32),
|
||||
];
|
||||
|
||||
$user = $this->registrationService->registerUser($details, null, false);
|
||||
$this->ldapService->saveAndAttachAvatar($user, $ldapUserDetails);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
/**
|
||||
* Class Ldap
|
||||
* An object-orientated thin abstraction wrapper for common PHP LDAP functions.
|
||||
* Allows the standard LDAP functions to be mocked for testing.
|
||||
*/
|
||||
class Ldap
|
||||
{
|
||||
/**
|
||||
* Connect to an LDAP server.
|
||||
*
|
||||
* @return resource|\LDAP\Connection|false
|
||||
*/
|
||||
public function connect(string $hostName)
|
||||
{
|
||||
return ldap_connect($hostName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of an LDAP option for the given connection.
|
||||
*
|
||||
* @param resource|\LDAP\Connection|null $ldapConnection
|
||||
*/
|
||||
public function setOption($ldapConnection, int $option, mixed $value): bool
|
||||
{
|
||||
return ldap_set_option($ldapConnection, $option, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start TLS on the given LDAP connection.
|
||||
*/
|
||||
public function startTls($ldapConnection): bool
|
||||
{
|
||||
return ldap_start_tls($ldapConnection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the version number for the given LDAP connection.
|
||||
*
|
||||
* @param resource|\LDAP\Connection $ldapConnection
|
||||
*/
|
||||
public function setVersion($ldapConnection, int $version): bool
|
||||
{
|
||||
return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search LDAP tree using the provided filter.
|
||||
*
|
||||
* @param resource|\LDAP\Connection $ldapConnection
|
||||
*
|
||||
* @return \LDAP\Result|array|false
|
||||
*/
|
||||
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = [])
|
||||
{
|
||||
return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an entry from the LDAP tree.
|
||||
*
|
||||
* @param resource|\Ldap\Connection $ldapConnection
|
||||
*
|
||||
* @return \LDAP\Result|array|false
|
||||
*/
|
||||
public function read($ldapConnection, string $baseDn, string $filter, array $attributes = [])
|
||||
{
|
||||
return ldap_read($ldapConnection, $baseDn, $filter, $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entries from an LDAP search result.
|
||||
*
|
||||
* @param resource|\LDAP\Connection $ldapConnection
|
||||
* @param resource|\LDAP\Result $ldapSearchResult
|
||||
*/
|
||||
public function getEntries($ldapConnection, $ldapSearchResult): array|false
|
||||
{
|
||||
return ldap_get_entries($ldapConnection, $ldapSearchResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search and get entries immediately.
|
||||
*
|
||||
* @param resource|\LDAP\Connection $ldapConnection
|
||||
*/
|
||||
public function searchAndGetEntries($ldapConnection, string $baseDn, string $filter, array $attributes = []): array|false
|
||||
{
|
||||
$search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
|
||||
|
||||
return $this->getEntries($ldapConnection, $search);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind to LDAP directory.
|
||||
*
|
||||
* @param resource|\LDAP\Connection $ldapConnection
|
||||
*/
|
||||
public function bind($ldapConnection, ?string $bindRdn = null, ?string $bindPassword = null): bool
|
||||
{
|
||||
return ldap_bind($ldapConnection, $bindRdn, $bindPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explode an LDAP dn string into an array of components.
|
||||
*/
|
||||
public function explodeDn(string $dn, int $withAttrib): array|false
|
||||
{
|
||||
return ldap_explode_dn($dn, $withAttrib);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for use in an LDAP filter.
|
||||
*/
|
||||
public function escape(string $value, string $ignore = '', int $flags = 0): string
|
||||
{
|
||||
return ldap_escape($value, $ignore, $flags);
|
||||
}
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\LdapException;
|
||||
use BookStack\Uploads\UserAvatars;
|
||||
use BookStack\Users\Models\User;
|
||||
use ErrorException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Class LdapService
|
||||
* Handles any app-specific LDAP tasks.
|
||||
*/
|
||||
class LdapService
|
||||
{
|
||||
/**
|
||||
* @var resource|\LDAP\Connection
|
||||
*/
|
||||
protected $ldapConnection;
|
||||
|
||||
protected array $config;
|
||||
protected bool $enabled;
|
||||
|
||||
public function __construct(
|
||||
protected Ldap $ldap,
|
||||
protected UserAvatars $userAvatars,
|
||||
protected GroupSyncService $groupSyncService
|
||||
) {
|
||||
$this->config = config('services.ldap');
|
||||
$this->enabled = config('auth.method') === 'ldap';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if groups should be synced.
|
||||
*/
|
||||
public function shouldSyncGroups(): bool
|
||||
{
|
||||
return $this->enabled && $this->config['user_to_groups'] !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for attributes for a specific user on the ldap.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
private function getUserWithAttributes(string $userName, array $attributes): ?array
|
||||
{
|
||||
$ldapConnection = $this->getConnection();
|
||||
$this->bindSystemUser($ldapConnection);
|
||||
|
||||
// Clean attributes
|
||||
foreach ($attributes as $index => $attribute) {
|
||||
if (str_starts_with($attribute, 'BIN;')) {
|
||||
$attributes[$index] = substr($attribute, strlen('BIN;'));
|
||||
}
|
||||
}
|
||||
|
||||
// Find user
|
||||
$userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]);
|
||||
$baseDn = $this->config['base_dn'];
|
||||
|
||||
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
|
||||
$this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
|
||||
$users = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $userFilter, $attributes);
|
||||
if ($users['count'] === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $users[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user display name from the (potentially multiple) attributes defined by the configuration.
|
||||
*/
|
||||
protected function getUserDisplayName(array $userDetails, array $displayNameAttrs, string $defaultValue): string
|
||||
{
|
||||
$displayNameParts = [];
|
||||
foreach ($displayNameAttrs as $dnAttr) {
|
||||
$dnComponent = $this->getUserResponseProperty($userDetails, $dnAttr, null);
|
||||
if ($dnComponent) {
|
||||
$displayNameParts[] = $dnComponent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($displayNameParts)) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
return implode(' ', $displayNameParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the details of a user from LDAP using the given username.
|
||||
* User found via configurable user filter.
|
||||
*
|
||||
* @throws LdapException|JsonDebugException
|
||||
*/
|
||||
public function getUserDetails(string $userName): ?array
|
||||
{
|
||||
$idAttr = $this->config['id_attribute'];
|
||||
$emailAttr = $this->config['email_attribute'];
|
||||
$displayNameAttrs = explode('|', $this->config['display_name_attribute']);
|
||||
$thumbnailAttr = $this->config['thumbnail_attribute'];
|
||||
|
||||
$user = $this->getUserWithAttributes($userName, array_filter([
|
||||
'cn', 'dn', $idAttr, $emailAttr, ...$displayNameAttrs, $thumbnailAttr,
|
||||
]));
|
||||
|
||||
if (is_null($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nameDefault = $this->getUserResponseProperty($user, 'cn', null);
|
||||
if (is_null($nameDefault)) {
|
||||
$nameDefault = ldap_explode_dn($user['dn'], 1)[0] ?? $user['dn'];
|
||||
}
|
||||
|
||||
$formatted = [
|
||||
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
|
||||
'name' => $this->getUserDisplayName($user, $displayNameAttrs, $nameDefault),
|
||||
'dn' => $user['dn'],
|
||||
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
|
||||
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
|
||||
];
|
||||
|
||||
if ($this->config['dump_user_details']) {
|
||||
throw new JsonDebugException([
|
||||
'details_from_ldap' => $user,
|
||||
'details_bookstack_parsed' => $formatted,
|
||||
]);
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a property from an LDAP user response fetch.
|
||||
* Handles properties potentially being part of an array.
|
||||
* If the given key is prefixed with 'BIN;', that indicator will be stripped
|
||||
* from the key and any fetched values will be converted from binary to hex.
|
||||
*/
|
||||
protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
|
||||
{
|
||||
$isBinary = str_starts_with($propertyKey, 'BIN;');
|
||||
$propertyKey = strtolower($propertyKey);
|
||||
$value = $defaultValue;
|
||||
|
||||
if ($isBinary) {
|
||||
$propertyKey = substr($propertyKey, strlen('BIN;'));
|
||||
}
|
||||
|
||||
if (isset($userDetails[$propertyKey])) {
|
||||
$value = (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
|
||||
if ($isBinary) {
|
||||
$value = bin2hex($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given credentials are valid for the given user.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
public function validateUserCredentials(?array $ldapUserDetails, string $password): bool
|
||||
{
|
||||
if (is_null($ldapUserDetails)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ldapConnection = $this->getConnection();
|
||||
|
||||
try {
|
||||
$ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password);
|
||||
} catch (ErrorException $e) {
|
||||
$ldapBind = false;
|
||||
}
|
||||
|
||||
return $ldapBind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the system user to the LDAP connection using the given credentials
|
||||
* otherwise anonymous access is attempted.
|
||||
*
|
||||
* @param resource|\LDAP\Connection $connection
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
protected function bindSystemUser($connection): void
|
||||
{
|
||||
$ldapDn = $this->config['dn'];
|
||||
$ldapPass = $this->config['pass'];
|
||||
|
||||
$isAnonymous = ($ldapDn === false || $ldapPass === false);
|
||||
if ($isAnonymous) {
|
||||
$ldapBind = $this->ldap->bind($connection);
|
||||
} else {
|
||||
$ldapBind = $this->ldap->bind($connection, $ldapDn, $ldapPass);
|
||||
}
|
||||
|
||||
if (!$ldapBind) {
|
||||
throw new LdapException(($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed')));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the connection to the LDAP server.
|
||||
* Creates a new connection if one does not exist.
|
||||
*
|
||||
* @throws LdapException
|
||||
*
|
||||
* @return resource|\LDAP\Connection
|
||||
*/
|
||||
protected function getConnection()
|
||||
{
|
||||
if ($this->ldapConnection !== null) {
|
||||
return $this->ldapConnection;
|
||||
}
|
||||
|
||||
// Check LDAP extension in installed
|
||||
if (!function_exists('ldap_connect') && config('app.env') !== 'testing') {
|
||||
throw new LdapException(trans('errors.ldap_extension_not_installed'));
|
||||
}
|
||||
|
||||
// Disable certificate verification.
|
||||
// This option works globally and must be set before a connection is created.
|
||||
if ($this->config['tls_insecure']) {
|
||||
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
|
||||
}
|
||||
|
||||
// Configure any user-provided CA cert files for LDAP.
|
||||
// This option works globally and must be set before a connection is created.
|
||||
if ($this->config['tls_ca_cert']) {
|
||||
$this->configureTlsCaCerts($this->config['tls_ca_cert']);
|
||||
}
|
||||
|
||||
$ldapHost = $this->parseServerString($this->config['server']);
|
||||
$ldapConnection = $this->ldap->connect($ldapHost);
|
||||
|
||||
if ($ldapConnection === false) {
|
||||
throw new LdapException(trans('errors.ldap_cannot_connect'));
|
||||
}
|
||||
|
||||
// Set any required options
|
||||
if ($this->config['version']) {
|
||||
$this->ldap->setVersion($ldapConnection, $this->config['version']);
|
||||
}
|
||||
|
||||
// Start and verify TLS if it's enabled
|
||||
if ($this->config['start_tls']) {
|
||||
try {
|
||||
$started = $this->ldap->startTls($ldapConnection);
|
||||
} catch (\Exception $exception) {
|
||||
$error = $exception->getMessage() . ' :: ' . ldap_error($ldapConnection);
|
||||
ldap_get_option($ldapConnection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $detail);
|
||||
Log::info("LDAP STARTTLS failure: {$error} {$detail}");
|
||||
throw new LdapException('Could not start TLS connection. Further details in the application log.');
|
||||
}
|
||||
if (!$started) {
|
||||
throw new LdapException('Could not start TLS connection');
|
||||
}
|
||||
}
|
||||
|
||||
$this->ldapConnection = $ldapConnection;
|
||||
|
||||
return $this->ldapConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure TLS CA certs globally for ldap use.
|
||||
* This will detect if the given path is a directory or file, and set the relevant
|
||||
* LDAP TLS options appropriately otherwise throw an exception if no file/folder found.
|
||||
*
|
||||
* Note: When using a folder, certificates are expected to be correctly named by hash
|
||||
* which can be done via the c_rehash utility.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
protected function configureTlsCaCerts(string $caCertPath): void
|
||||
{
|
||||
$errMessage = "Provided path [{$caCertPath}] for LDAP TLS CA certs could not be resolved to an existing location";
|
||||
$path = realpath($caCertPath);
|
||||
if ($path === false) {
|
||||
throw new LdapException($errMessage);
|
||||
}
|
||||
|
||||
if (is_dir($path)) {
|
||||
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTDIR, $path);
|
||||
} else if (is_file($path)) {
|
||||
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTFILE, $path);
|
||||
} else {
|
||||
throw new LdapException($errMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an LDAP server string and return the host suitable for a connection.
|
||||
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
|
||||
*/
|
||||
protected function parseServerString(string $serverString): string
|
||||
{
|
||||
if (str_starts_with($serverString, 'ldaps://') || str_starts_with($serverString, 'ldap://')) {
|
||||
return $serverString;
|
||||
}
|
||||
|
||||
return "ldap://{$serverString}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a filter string by injecting common variables.
|
||||
* Both "${var}" and "{var}" style placeholders are supported.
|
||||
* Dollar based are old format but supported for compatibility.
|
||||
*/
|
||||
protected function buildFilter(string $filterString, array $attrs): string
|
||||
{
|
||||
$newAttrs = [];
|
||||
foreach ($attrs as $key => $attrText) {
|
||||
$escapedText = $this->ldap->escape($attrText);
|
||||
$oldVarKey = '${' . $key . '}';
|
||||
$newVarKey = '{' . $key . '}';
|
||||
$newAttrs[$oldVarKey] = $escapedText;
|
||||
$newAttrs[$newVarKey] = $escapedText;
|
||||
}
|
||||
|
||||
return strtr($filterString, $newAttrs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the groups a user is a part of on ldap.
|
||||
*
|
||||
* @throws LdapException
|
||||
* @throws JsonDebugException
|
||||
*/
|
||||
public function getUserGroups(string $userName): array
|
||||
{
|
||||
$groupsAttr = $this->config['group_attribute'];
|
||||
$user = $this->getUserWithAttributes($userName, [$groupsAttr]);
|
||||
|
||||
if ($user === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$userGroups = $this->extractGroupsFromSearchResponseEntry($user);
|
||||
$allGroups = $this->getGroupsRecursive($userGroups, []);
|
||||
$formattedGroups = $this->extractGroupNamesFromLdapGroupDns($allGroups);
|
||||
|
||||
if ($this->config['dump_user_groups']) {
|
||||
throw new JsonDebugException([
|
||||
'details_from_ldap' => $user,
|
||||
'parsed_direct_user_groups' => $userGroups,
|
||||
'parsed_recursive_user_groups' => $allGroups,
|
||||
'parsed_resulting_group_names' => $formattedGroups,
|
||||
]);
|
||||
}
|
||||
|
||||
return $formattedGroups;
|
||||
}
|
||||
|
||||
protected function extractGroupNamesFromLdapGroupDns(array $groupDNs): array
|
||||
{
|
||||
$names = [];
|
||||
|
||||
foreach ($groupDNs as $groupDN) {
|
||||
$exploded = $this->ldap->explodeDn($groupDN, 1);
|
||||
if ($exploded !== false && count($exploded) > 0) {
|
||||
$names[] = $exploded[0];
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an array of all relevant groups DNs after recursively scanning
|
||||
* across parents of the groups given.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
protected function getGroupsRecursive(array $groupDNs, array $checked): array
|
||||
{
|
||||
$groupsToAdd = [];
|
||||
foreach ($groupDNs as $groupDN) {
|
||||
if (in_array($groupDN, $checked)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parentGroups = $this->getParentsOfGroup($groupDN);
|
||||
$groupsToAdd = array_merge($groupsToAdd, $parentGroups);
|
||||
$checked[] = $groupDN;
|
||||
}
|
||||
|
||||
$uniqueDNs = array_unique(array_merge($groupDNs, $groupsToAdd), SORT_REGULAR);
|
||||
|
||||
if (empty($groupsToAdd)) {
|
||||
return $uniqueDNs;
|
||||
}
|
||||
|
||||
return $this->getGroupsRecursive($uniqueDNs, $checked);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws LdapException
|
||||
*/
|
||||
protected function getParentsOfGroup(string $groupDN): array
|
||||
{
|
||||
$groupsAttr = strtolower($this->config['group_attribute']);
|
||||
$ldapConnection = $this->getConnection();
|
||||
$this->bindSystemUser($ldapConnection);
|
||||
|
||||
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
|
||||
$this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
|
||||
$read = $this->ldap->read($ldapConnection, $groupDN, '(objectClass=*)', [$groupsAttr]);
|
||||
$results = $this->ldap->getEntries($ldapConnection, $read);
|
||||
if ($results['count'] === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractGroupsFromSearchResponseEntry($results[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an array of group DN values from the given LDAP search response entry
|
||||
*/
|
||||
protected function extractGroupsFromSearchResponseEntry(array $ldapEntry): array
|
||||
{
|
||||
$groupsAttr = strtolower($this->config['group_attribute']);
|
||||
$groupDNs = [];
|
||||
$count = 0;
|
||||
|
||||
if (isset($ldapEntry[$groupsAttr]['count'])) {
|
||||
$count = (int) $ldapEntry[$groupsAttr]['count'];
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$dn = $ldapEntry[$groupsAttr][$i];
|
||||
if (!in_array($dn, $groupDNs)) {
|
||||
$groupDNs[] = $dn;
|
||||
}
|
||||
}
|
||||
|
||||
return $groupDNs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the LDAP groups to the user roles for the current user.
|
||||
*
|
||||
* @throws LdapException
|
||||
* @throws JsonDebugException
|
||||
*/
|
||||
public function syncGroups(User $user, string $username): void
|
||||
{
|
||||
$userLdapGroups = $this->getUserGroups($username);
|
||||
$this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and attach an avatar image, if found in the ldap details, and attach
|
||||
* to the given user model.
|
||||
*/
|
||||
public function saveAndAttachAvatar(User $user, array $ldapUserDetails): void
|
||||
{
|
||||
if (is_null(config('services.ldap.thumbnail_attribute')) || is_null($ldapUserDetails['avatar'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$imageData = $ldapUserDetails['avatar'];
|
||||
$this->userAvatars->assignToUserFromExistingData($user, $imageData, 'jpg');
|
||||
} catch (\Exception $exception) {
|
||||
Log::info("Failed to use avatar image from LDAP data for user id {$user->id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Access\Mfa\MfaSession;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Exceptions\LoginAttemptInvalidUserException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Users\Models\User;
|
||||
use Exception;
|
||||
|
||||
class LoginService
|
||||
{
|
||||
protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
|
||||
|
||||
public function __construct(
|
||||
protected MfaSession $mfaSession,
|
||||
protected EmailConfirmationService $emailConfirmationService,
|
||||
protected SocialDriverManager $socialDriverManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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|LoginAttemptInvalidUserException
|
||||
*/
|
||||
public function login(User $user, string $method, bool $remember = false): void
|
||||
{
|
||||
if ($user->isGuest()) {
|
||||
throw new LoginAttemptInvalidUserException('Login not allowed for guest user');
|
||||
}
|
||||
|
||||
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(Permission::UsersManage) && $user->can(Permission::UserRolesManage)) {
|
||||
$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): void
|
||||
{
|
||||
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, 'remember' => false];
|
||||
}
|
||||
|
||||
[$id, $method, $remember, $time] = explode(':', $value);
|
||||
$hourAgo = time() - (60 * 60);
|
||||
if ($time < $hourAgo) {
|
||||
$this->clearLastLoginAttempted();
|
||||
|
||||
return ['user_id' => null, 'method' => null, 'remember' => false];
|
||||
}
|
||||
|
||||
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): void
|
||||
{
|
||||
session()->put(
|
||||
self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,
|
||||
implode(':', [$user->id, $method, $remember, time()])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the last login attempted session value.
|
||||
*/
|
||||
protected function clearLastLoginAttempted(): void
|
||||
{
|
||||
session()->remove(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MFA verification is needed.
|
||||
*/
|
||||
public function needsMfaVerification(User $user): bool
|
||||
{
|
||||
return !$this->mfaSession->isVerifiedForUser($user) && $this->mfaSession->isRequiredForUser($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given user is awaiting email confirmation.
|
||||
*/
|
||||
public function awaitingEmailConfirmation(User $user): bool
|
||||
{
|
||||
return $this->emailConfirmationService->confirmationRequired() && !$user->email_confirmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt the login of a user using the given credentials.
|
||||
* Meant to mirror Laravel's default guard 'attempt' method
|
||||
* but in a manner that always routes through our login system.
|
||||
* May interrupt the flow if extra authentication requirements are imposed.
|
||||
*
|
||||
* @throws StoppedAuthenticationException
|
||||
* @throws LoginAttemptException
|
||||
*/
|
||||
public function attempt(array $credentials, string $method, bool $remember = false): bool
|
||||
{
|
||||
if ($this->areCredentialsForGuest($credentials)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = auth()->attempt($credentials, $remember);
|
||||
if ($result) {
|
||||
$user = auth()->user();
|
||||
auth()->logout();
|
||||
try {
|
||||
$this->login($user, $method, $remember);
|
||||
} catch (LoginAttemptInvalidUserException $e) {
|
||||
// Catch and return false for non-login accounts
|
||||
// so it looks like a normal invalid login.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given credentials are likely for the system guest account.
|
||||
*/
|
||||
protected function areCredentialsForGuest(array $credentials): bool
|
||||
{
|
||||
if (isset($credentials['email'])) {
|
||||
return User::query()->where('email', '=', $credentials['email'])
|
||||
->where('system_name', '=', 'public')
|
||||
->exists();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the current user out of the application.
|
||||
* Returns an app post-redirect path.
|
||||
*/
|
||||
public function logout(): string
|
||||
{
|
||||
auth()->logout();
|
||||
session()->invalidate();
|
||||
session()->regenerateToken();
|
||||
|
||||
return $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if login auto-initiate should be active based upon authentication config.
|
||||
*/
|
||||
public function shouldAutoInitiate(): bool
|
||||
{
|
||||
$autoRedirect = config('auth.auto_initiate');
|
||||
if (!$autoRedirect) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
$authMethod = config('auth.method');
|
||||
|
||||
return count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Mfa;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BackupCodeService
|
||||
{
|
||||
/**
|
||||
* Generate a new set of 16 backup codes.
|
||||
*/
|
||||
public function generateNewSet(): array
|
||||
{
|
||||
$codes = [];
|
||||
while (count($codes) < 16) {
|
||||
$code = Str::random(5) . '-' . Str::random(5);
|
||||
if (!in_array($code, $codes)) {
|
||||
$codes[] = strtolower($code);
|
||||
}
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given code matches one of the available options.
|
||||
*/
|
||||
public function inputCodeExistsInSet(string $code, string $codeSet): bool
|
||||
{
|
||||
$cleanCode = $this->cleanInputCode($code);
|
||||
$codes = json_decode($codeSet);
|
||||
|
||||
return in_array($cleanCode, $codes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given input code from the given available options.
|
||||
* Will return a JSON string containing the codes.
|
||||
*/
|
||||
public function removeInputCodeFromSet(string $code, string $codeSet): string
|
||||
{
|
||||
$cleanCode = $this->cleanInputCode($code);
|
||||
$codes = json_decode($codeSet);
|
||||
$pos = array_search($cleanCode, $codes, true);
|
||||
array_splice($codes, $pos, 1);
|
||||
|
||||
return json_encode($codes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of codes in the given set.
|
||||
*/
|
||||
public function countCodesInSet(string $codeSet): int
|
||||
{
|
||||
return count(json_decode($codeSet));
|
||||
}
|
||||
|
||||
protected function cleanInputCode(string $code): string
|
||||
{
|
||||
return strtolower(str_replace(' ', '-', trim($code)));
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Mfa;
|
||||
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
class MfaSession
|
||||
{
|
||||
/**
|
||||
* Check if MFA is required for the given user.
|
||||
*/
|
||||
public function isRequiredForUser(User $user): bool
|
||||
{
|
||||
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given user is pending MFA setup.
|
||||
* (MFA required but not yet configured).
|
||||
*/
|
||||
public function isPendingMfaSetup(User $user): bool
|
||||
{
|
||||
return $this->isRequiredForUser($user) && !$user->mfaValues()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role of the given user enforces MFA.
|
||||
*/
|
||||
protected function userRoleEnforcesMfa(User $user): bool
|
||||
{
|
||||
return $user->roles()
|
||||
->where('mfa_enforced', '=', true)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current MFA session has already been verified for the given user.
|
||||
*/
|
||||
public function isVerifiedForUser(User $user): bool
|
||||
{
|
||||
return session()->get($this->getMfaVerifiedSessionKey($user)) === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the current session as MFA-verified.
|
||||
*/
|
||||
public function markVerifiedForUser(User $user): void
|
||||
{
|
||||
session()->put($this->getMfaVerifiedSessionKey($user), 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session key in which the MFA verification status is stored.
|
||||
*/
|
||||
protected function getMfaVerifiedSessionKey(User $user): string
|
||||
{
|
||||
return 'mfa-verification-passed:' . $user->id;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Mfa;
|
||||
|
||||
use BookStack\Users\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
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
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected static $unguarded = true;
|
||||
|
||||
const METHOD_TOTP = 'totp';
|
||||
const METHOD_BACKUP_CODES = 'backup_codes';
|
||||
|
||||
/**
|
||||
* Get all the MFA methods available.
|
||||
*/
|
||||
public static function allMethods(): array
|
||||
{
|
||||
return [self::METHOD_TOTP, self::METHOD_BACKUP_CODES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a new MFA value for the given user and method
|
||||
* using the provided value.
|
||||
*/
|
||||
public static function upsertWithValue(User $user, string $method, string $value): void
|
||||
{
|
||||
/** @var MfaValue $mfaVal */
|
||||
$mfaVal = static::query()->firstOrNew([
|
||||
'user_id' => $user->id,
|
||||
'method' => $method,
|
||||
]);
|
||||
$mfaVal->setValue($value);
|
||||
$mfaVal->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Easily get the decrypted MFA value for the given user and method.
|
||||
*/
|
||||
public static function getValueForUser(User $user, string $method): ?string
|
||||
{
|
||||
/** @var MfaValue $mfaVal */
|
||||
$mfaVal = static::query()
|
||||
->where('user_id', '=', $user->id)
|
||||
->where('method', '=', $method)
|
||||
->first();
|
||||
|
||||
return $mfaVal ? $mfaVal->getValue() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the value attribute upon access.
|
||||
*/
|
||||
protected function getValue(): string
|
||||
{
|
||||
return decrypt($this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the value attribute upon access.
|
||||
*/
|
||||
protected function setValue($value): void
|
||||
{
|
||||
$this->value = encrypt($value);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\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\Users\Models\User;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use PragmaRX\Google2FA\Support\Constants;
|
||||
|
||||
class TotpService
|
||||
{
|
||||
public function __construct(
|
||||
protected 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 a secret key.
|
||||
*/
|
||||
public function generateUrl(string $secret, User $user): string
|
||||
{
|
||||
return $this->google2fa->getQRCodeUrl(
|
||||
setting('app-name'),
|
||||
$user->email,
|
||||
$secret
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code to display a TOTP URL.
|
||||
*/
|
||||
public function generateQrCodeSvg(string $url): string
|
||||
{
|
||||
$color = Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(32, 110, 167));
|
||||
|
||||
return (new Writer(
|
||||
new ImageRenderer(
|
||||
new RendererStyle(192, 4, null, null, $color),
|
||||
new SvgImageBackEnd()
|
||||
)
|
||||
))->writeString($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the user provided code is valid for the secret.
|
||||
* The secret must be known, not user-provided.
|
||||
*/
|
||||
public function verifyCode(string $code, string $secret): bool
|
||||
{
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
return $this->google2fa->verifyKey($secret, $code);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Mfa;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class TotpValidationRule implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Create a new rule instance.
|
||||
* Takes the TOTP secret that must be system provided, not user provided.
|
||||
*/
|
||||
public function __construct(
|
||||
protected string $secret,
|
||||
protected TotpService $totpService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$passes = $this->totpService->verifyCode($value, $this->secret);
|
||||
if (!$passes) {
|
||||
$fail(trans('validation.totp'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Notifications;
|
||||
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class ConfirmEmailNotification extends MailNotification
|
||||
{
|
||||
public function __construct(
|
||||
public string $token
|
||||
) {
|
||||
}
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$appName = ['appName' => setting('app-name')];
|
||||
|
||||
return $this->newMailMessage()
|
||||
->subject(trans('auth.email_confirm_subject', $appName))
|
||||
->greeting(trans('auth.email_confirm_greeting', $appName))
|
||||
->line(trans('auth.email_confirm_text'))
|
||||
->action(trans('auth.email_confirm_action'), url('/register/confirm/' . $this->token));
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Notifications;
|
||||
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class ResetPasswordNotification extends MailNotification
|
||||
{
|
||||
public function __construct(
|
||||
public string $token
|
||||
) {
|
||||
}
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
return $this->newMailMessage()
|
||||
->subject(trans('auth.email_reset_subject', ['appName' => setting('app-name')]))
|
||||
->line(trans('auth.email_reset_text'))
|
||||
->action(trans('auth.reset_password'), url('password/reset/' . $this->token))
|
||||
->line(trans('auth.email_reset_not_requested'));
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Notifications;
|
||||
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class UserInviteNotification extends MailNotification
|
||||
{
|
||||
public function __construct(
|
||||
public string $token
|
||||
) {
|
||||
}
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$appName = ['appName' => setting('app-name')];
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
->subject($locale->trans('auth.user_invite_email_subject', $appName))
|
||||
->greeting($locale->trans('auth.user_invite_email_greeting', $appName))
|
||||
->line($locale->trans('auth.user_invite_email_text'))
|
||||
->action($locale->trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use League\OAuth2\Client\Token\AccessToken;
|
||||
|
||||
class OidcAccessToken extends AccessToken
|
||||
{
|
||||
/**
|
||||
* Constructs an access token.
|
||||
*
|
||||
* @param array $options An array of options returned by the service provider
|
||||
* in the access token request. The `access_token` option is required.
|
||||
*
|
||||
* @throws InvalidArgumentException if `access_token` is not provided in `$options`.
|
||||
*/
|
||||
public function __construct(array $options = [])
|
||||
{
|
||||
parent::__construct($options);
|
||||
$this->validate($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate this access token response for OIDC.
|
||||
* As per https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
|
||||
*/
|
||||
private function validate(array $options): void
|
||||
{
|
||||
// access_token: REQUIRED. Access Token for the UserInfo Endpoint.
|
||||
// Performed on the extended class
|
||||
|
||||
// token_type: REQUIRED. OAuth 2.0 Token Type value. The value MUST be Bearer, as specified in OAuth 2.0
|
||||
// Bearer Token Usage [RFC6750], for Clients using this subset.
|
||||
// Note that the token_type value is case-insensitive.
|
||||
if (strtolower(($options['token_type'] ?? '')) !== 'bearer') {
|
||||
throw new InvalidArgumentException('The response token type MUST be "Bearer"');
|
||||
}
|
||||
|
||||
// id_token: REQUIRED. ID Token.
|
||||
if (empty($options['id_token'])) {
|
||||
throw new InvalidArgumentException('An "id_token" property must be provided');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the id token value from this access token response.
|
||||
*/
|
||||
public function getIdToken(): string
|
||||
{
|
||||
return $this->getValues()['id_token'];
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
use Exception;
|
||||
|
||||
class OidcException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
class OidcIdToken extends OidcJwtWithClaims implements ProvidesClaims
|
||||
{
|
||||
/**
|
||||
* Validate all possible parts of the id token.
|
||||
*
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
public function validate(string $clientId): bool
|
||||
{
|
||||
parent::validateCommonTokenDetails($clientId);
|
||||
$this->validateTokenClaims($clientId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the claims of the token.
|
||||
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
|
||||
*
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
protected function validateTokenClaims(string $clientId): void
|
||||
{
|
||||
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
|
||||
// MUST exactly match the value of the iss (issuer) Claim.
|
||||
// Already done in parent.
|
||||
|
||||
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
|
||||
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
|
||||
// if the ID Token does not list the Client as a valid audience, or if it contains additional
|
||||
// audiences not trusted by the Client.
|
||||
// Partially done in parent.
|
||||
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
|
||||
if (count($aud) !== 1) {
|
||||
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
|
||||
}
|
||||
|
||||
// 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
|
||||
// NOTE: Addressed by enforcing a count of 1 above.
|
||||
|
||||
// 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id
|
||||
// is the Claim Value.
|
||||
if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) {
|
||||
throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id');
|
||||
}
|
||||
|
||||
// 5. The current time MUST be before the time represented by the exp Claim
|
||||
// (possibly allowing for some small leeway to account for clock skew).
|
||||
if (empty($this->payload['exp'])) {
|
||||
throw new OidcInvalidTokenException('Missing token expiration time value');
|
||||
}
|
||||
|
||||
$skewSeconds = 120;
|
||||
$now = time();
|
||||
if ($now >= (intval($this->payload['exp']) + $skewSeconds)) {
|
||||
throw new OidcInvalidTokenException('Token has expired');
|
||||
}
|
||||
|
||||
// 6. The iat Claim can be used to reject tokens that were issued too far away from the current time,
|
||||
// limiting the amount of time that nonces need to be stored to prevent attacks.
|
||||
// The acceptable range is Client specific.
|
||||
if (empty($this->payload['iat'])) {
|
||||
throw new OidcInvalidTokenException('Missing token issued at time value');
|
||||
}
|
||||
|
||||
$dayAgo = time() - 86400;
|
||||
$iat = intval($this->payload['iat']);
|
||||
if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) {
|
||||
throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid');
|
||||
}
|
||||
|
||||
// 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate.
|
||||
// The meaning and processing of acr Claim Values is out of scope for this document.
|
||||
// NOTE: Not used for our case here. acr is not requested.
|
||||
|
||||
// 8. When a max_age request is made, the Client SHOULD check the auth_time Claim value and request
|
||||
// re-authentication if it determines too much time has elapsed since the last End-User authentication.
|
||||
// NOTE: Not used for our case here. A max_age request is not made.
|
||||
|
||||
// Custom: Ensure the "sub" (Subject) Claim exists and has a value.
|
||||
if (empty($this->payload['sub'])) {
|
||||
throw new OidcInvalidTokenException('Missing token subject value');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
class OidcInvalidKeyException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
use Exception;
|
||||
|
||||
class OidcInvalidTokenException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
use Exception;
|
||||
|
||||
class OidcIssuerDiscoveryException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
use phpseclib3\Crypt\Common\PublicKey;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
use phpseclib3\Crypt\RSA;
|
||||
use phpseclib3\Math\BigInteger;
|
||||
|
||||
class OidcJwtSigningKey
|
||||
{
|
||||
/**
|
||||
* @var PublicKey
|
||||
*/
|
||||
protected $key;
|
||||
|
||||
/**
|
||||
* Can be created either from a JWK parameter array or local file path to load a certificate from.
|
||||
* Examples:
|
||||
* 'file:///var/www/cert.pem'
|
||||
* ['kty' => 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'].
|
||||
*
|
||||
* @param array|string $jwkOrKeyPath
|
||||
*
|
||||
* @throws OidcInvalidKeyException
|
||||
*/
|
||||
public function __construct($jwkOrKeyPath)
|
||||
{
|
||||
if (is_array($jwkOrKeyPath)) {
|
||||
$this->loadFromJwkArray($jwkOrKeyPath);
|
||||
} elseif (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) {
|
||||
$this->loadFromPath($jwkOrKeyPath);
|
||||
} else {
|
||||
throw new OidcInvalidKeyException('Unexpected type of key value provided');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OidcInvalidKeyException
|
||||
*/
|
||||
protected function loadFromPath(string $path)
|
||||
{
|
||||
try {
|
||||
$key = PublicKeyLoader::load(
|
||||
file_get_contents($path)
|
||||
);
|
||||
} catch (\Exception $exception) {
|
||||
throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}");
|
||||
}
|
||||
|
||||
if (!$key instanceof RSA) {
|
||||
throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
|
||||
}
|
||||
|
||||
$this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OidcInvalidKeyException
|
||||
*/
|
||||
protected function loadFromJwkArray(array $jwk)
|
||||
{
|
||||
// 'alg' is optional for a JWK, but we will still attempt to validate if
|
||||
// it exists otherwise presume it will be compatible.
|
||||
$alg = $jwk['alg'] ?? null;
|
||||
if ($jwk['kty'] !== 'RSA' || !(is_null($alg) || $alg === 'RS256')) {
|
||||
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
|
||||
}
|
||||
|
||||
// 'use' is optional for a JWK but we assume 'sig' where no value exists since that's what
|
||||
// the OIDC discovery spec infers since 'sig' MUST be set if encryption keys come into play.
|
||||
$use = $jwk['use'] ?? 'sig';
|
||||
if ($use !== 'sig') {
|
||||
throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
|
||||
}
|
||||
|
||||
if (empty($jwk['e'])) {
|
||||
throw new OidcInvalidKeyException('An "e" parameter on the provided key is expected');
|
||||
}
|
||||
|
||||
if (empty($jwk['n'])) {
|
||||
throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected');
|
||||
}
|
||||
|
||||
$n = strtr($jwk['n'] ?? '', '-_', '+/');
|
||||
|
||||
try {
|
||||
$key = PublicKeyLoader::load([
|
||||
'e' => new BigInteger(base64_decode($jwk['e']), 256),
|
||||
'n' => new BigInteger(base64_decode($n), 256),
|
||||
]);
|
||||
} catch (\Exception $exception) {
|
||||
throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}");
|
||||
}
|
||||
|
||||
if (!$key instanceof RSA) {
|
||||
throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
|
||||
}
|
||||
|
||||
$this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this key to sign the given content and return the signature.
|
||||
*/
|
||||
public function verify(string $content, string $signature): bool
|
||||
{
|
||||
return $this->key->verify($content, $signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the key to a PEM encoded key string.
|
||||
*/
|
||||
public function toPem(): string
|
||||
{
|
||||
return $this->key->toString('PKCS8');
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
class OidcJwtWithClaims implements ProvidesClaims
|
||||
{
|
||||
protected array $header;
|
||||
protected array $payload;
|
||||
protected string $signature;
|
||||
protected string $issuer;
|
||||
protected array $tokenParts = [];
|
||||
|
||||
/**
|
||||
* @var array[]|string[]
|
||||
*/
|
||||
protected array $keys;
|
||||
|
||||
public function __construct(string $token, string $issuer, array $keys)
|
||||
{
|
||||
$this->keys = $keys;
|
||||
$this->issuer = $issuer;
|
||||
$this->parse($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the token content into its components.
|
||||
*/
|
||||
protected function parse(string $token): void
|
||||
{
|
||||
$this->tokenParts = explode('.', $token);
|
||||
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
|
||||
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
|
||||
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Base64-JSON encoded token part.
|
||||
* Returns the data as a key-value array or empty array upon error.
|
||||
*/
|
||||
protected function parseEncodedTokenPart(string $part): array
|
||||
{
|
||||
$json = $this->base64UrlDecode($part) ?: '{}';
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64URL decode. Needs some character conversions to be compatible
|
||||
* with PHP's default base64 handling.
|
||||
*/
|
||||
protected function base64UrlDecode(string $encoded): string
|
||||
{
|
||||
return base64_decode(strtr($encoded, '-_', '+/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate common parts of OIDC JWT tokens.
|
||||
*
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
public function validateCommonTokenDetails(string $clientId): bool
|
||||
{
|
||||
$this->validateTokenStructure();
|
||||
$this->validateTokenSignature();
|
||||
$this->validateCommonClaims($clientId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific claim from this token.
|
||||
* Returns null if it is null or does not exist.
|
||||
*/
|
||||
public function getClaim(string $claim): mixed
|
||||
{
|
||||
return $this->payload[$claim] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all returned claims within the token.
|
||||
*/
|
||||
public function getAllClaims(): array
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the existing claim data of this token with that provided.
|
||||
*/
|
||||
public function replaceClaims(array $claims): void
|
||||
{
|
||||
$this->payload = $claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the structure of the given token and ensure we have the required pieces.
|
||||
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
|
||||
*
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
protected function validateTokenStructure(): void
|
||||
{
|
||||
foreach (['header', 'payload'] as $prop) {
|
||||
if (empty($this->$prop) || !is_array($this->$prop)) {
|
||||
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($this->signature) || !is_string($this->signature)) {
|
||||
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the signature of the given token and ensure it validates against the provided key.
|
||||
*
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
protected function validateTokenSignature(): void
|
||||
{
|
||||
if ($this->header['alg'] !== 'RS256') {
|
||||
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
|
||||
}
|
||||
|
||||
$parsedKeys = array_map(function ($key) {
|
||||
try {
|
||||
return new OidcJwtSigningKey($key);
|
||||
} catch (OidcInvalidKeyException $e) {
|
||||
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
|
||||
}
|
||||
}, $this->keys);
|
||||
|
||||
$parsedKeys = array_filter($parsedKeys);
|
||||
|
||||
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
|
||||
/** @var OidcJwtSigningKey $parsedKey */
|
||||
foreach ($parsedKeys as $parsedKey) {
|
||||
if ($parsedKey->verify($contentToSign, $this->signature)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate common claims for OIDC JWT tokens.
|
||||
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation
|
||||
* and https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
|
||||
*
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
protected function validateCommonClaims(string $clientId): void
|
||||
{
|
||||
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
|
||||
// MUST exactly match the value of the iss (issuer) Claim.
|
||||
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
|
||||
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
|
||||
}
|
||||
|
||||
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
|
||||
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
|
||||
// if the ID Token does not list the Client as a valid audience.
|
||||
if (empty($this->payload['aud'])) {
|
||||
throw new OidcInvalidTokenException('Missing token audience value');
|
||||
}
|
||||
|
||||
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
|
||||
if (!in_array($clientId, $aud, true)) {
|
||||
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
use League\OAuth2\Client\Grant\AbstractGrant;
|
||||
use League\OAuth2\Client\Provider\AbstractProvider;
|
||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
||||
use League\OAuth2\Client\Provider\GenericResourceOwner;
|
||||
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
|
||||
use League\OAuth2\Client\Token\AccessToken;
|
||||
use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Extended OAuth2Provider for using with OIDC.
|
||||
* Credit to the https://github.com/steverhoades/oauth2-openid-connect-client
|
||||
* project for the idea of extending a League\OAuth2 client for this use-case.
|
||||
*/
|
||||
class OidcOAuthProvider extends AbstractProvider
|
||||
{
|
||||
use BearerAuthorizationTrait;
|
||||
|
||||
protected string $authorizationEndpoint;
|
||||
protected string $tokenEndpoint;
|
||||
|
||||
/**
|
||||
* Scopes to use for the OIDC authorization call.
|
||||
*/
|
||||
protected array $scopes = ['openid', 'profile', 'email'];
|
||||
|
||||
/**
|
||||
* Returns the base URL for authorizing a client.
|
||||
*/
|
||||
public function getBaseAuthorizationUrl(): string
|
||||
{
|
||||
return $this->authorizationEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base URL for requesting an access token.
|
||||
*/
|
||||
public function getBaseAccessTokenUrl(array $params): string
|
||||
{
|
||||
return $this->tokenEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL for requesting the resource owner's details.
|
||||
*/
|
||||
public function getResourceOwnerDetailsUrl(AccessToken $token): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add another scope to this provider upon the default.
|
||||
*/
|
||||
public function addScope(string $scope): void
|
||||
{
|
||||
$this->scopes[] = $scope;
|
||||
$this->scopes = array_unique($this->scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default scopes used by this provider.
|
||||
*
|
||||
* This should only be the scopes that are required to request the details
|
||||
* of the resource owner, rather than all the available scopes.
|
||||
*/
|
||||
protected function getDefaultScopes(): array
|
||||
{
|
||||
return $this->scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the string that should be used to separate scopes when building
|
||||
* the URL for requesting an access token.
|
||||
*/
|
||||
protected function getScopeSeparator(): string
|
||||
{
|
||||
return ' ';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a provider response for errors.
|
||||
* @throws IdentityProviderException
|
||||
*/
|
||||
protected function checkResponse(ResponseInterface $response, $data): void
|
||||
{
|
||||
if ($response->getStatusCode() >= 400 || isset($data['error'])) {
|
||||
throw new IdentityProviderException(
|
||||
$data['error'] ?? $response->getReasonPhrase(),
|
||||
$response->getStatusCode(),
|
||||
(string) $response->getBody()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a resource owner object from a successful resource owner
|
||||
* details request.
|
||||
*/
|
||||
protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface
|
||||
{
|
||||
return new GenericResourceOwner($response, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an access token from a response.
|
||||
*
|
||||
* The grant that was used to fetch the response can be used to provide
|
||||
* additional context.
|
||||
*/
|
||||
protected function createAccessToken(array $response, AbstractGrant $grant): OidcAccessToken
|
||||
{
|
||||
return new OidcAccessToken($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the method used for PKCE code verifier hashing, which is passed
|
||||
* in the "code_challenge_method" parameter in the authorization request.
|
||||
*/
|
||||
protected function getPkceMethod(): string
|
||||
{
|
||||
return static::PKCE_METHOD_S256;
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use Illuminate\Contracts\Cache\Repository;
|
||||
use InvalidArgumentException;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
|
||||
/**
|
||||
* OpenIdConnectProviderSettings
|
||||
* Acts as a DTO for settings used within the oidc request and token handling.
|
||||
* Performs auto-discovery upon request.
|
||||
*/
|
||||
class OidcProviderSettings
|
||||
{
|
||||
public string $issuer;
|
||||
public string $clientId;
|
||||
public string $clientSecret;
|
||||
public ?string $authorizationEndpoint;
|
||||
public ?string $tokenEndpoint;
|
||||
public ?string $endSessionEndpoint;
|
||||
public ?string $userinfoEndpoint;
|
||||
|
||||
/**
|
||||
* @var string[]|array[]
|
||||
*/
|
||||
public ?array $keys = [];
|
||||
|
||||
public function __construct(array $settings)
|
||||
{
|
||||
$this->applySettingsFromArray($settings);
|
||||
$this->validateInitial();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an array of settings to populate setting properties within this class.
|
||||
*/
|
||||
protected function applySettingsFromArray(array $settingsArray): void
|
||||
{
|
||||
foreach ($settingsArray as $key => $value) {
|
||||
if (property_exists($this, $key)) {
|
||||
$this->$key = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate any core, required properties have been set.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function validateInitial(): void
|
||||
{
|
||||
$required = ['clientId', 'clientSecret', 'issuer'];
|
||||
foreach ($required as $prop) {
|
||||
if (empty($this->$prop)) {
|
||||
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
|
||||
}
|
||||
}
|
||||
|
||||
if (!str_starts_with($this->issuer, 'https://')) {
|
||||
throw new InvalidArgumentException('Issuer value must start with https://');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a full validation on these settings.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function validate(): void
|
||||
{
|
||||
$this->validateInitial();
|
||||
|
||||
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
|
||||
foreach ($required as $prop) {
|
||||
if (empty($this->$prop)) {
|
||||
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
|
||||
}
|
||||
}
|
||||
|
||||
$endpointProperties = ['tokenEndpoint', 'authorizationEndpoint', 'userinfoEndpoint'];
|
||||
foreach ($endpointProperties as $prop) {
|
||||
if (is_string($this->$prop) && !str_starts_with($this->$prop, 'https://')) {
|
||||
throw new InvalidArgumentException("Endpoint value for \"{$prop}\" must start with https://");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and autoload settings from the configured issuer.
|
||||
*
|
||||
* @throws OidcIssuerDiscoveryException
|
||||
*/
|
||||
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes): void
|
||||
{
|
||||
try {
|
||||
$cacheKey = 'oidc-discovery::' . $this->issuer;
|
||||
$discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function () use ($httpClient) {
|
||||
return $this->loadSettingsFromIssuerDiscovery($httpClient);
|
||||
});
|
||||
$this->applySettingsFromArray($discoveredSettings);
|
||||
} catch (ClientExceptionInterface $exception) {
|
||||
throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OidcIssuerDiscoveryException
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array
|
||||
{
|
||||
$issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration';
|
||||
$request = new Request('GET', $issuerUrl);
|
||||
$response = $httpClient->sendRequest($request);
|
||||
$result = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if (empty($result) || !is_array($result)) {
|
||||
throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}");
|
||||
}
|
||||
|
||||
if ($result['issuer'] !== $this->issuer) {
|
||||
throw new OidcIssuerDiscoveryException('Unexpected issuer value found on discovery response');
|
||||
}
|
||||
|
||||
$discoveredSettings = [];
|
||||
|
||||
if (!empty($result['authorization_endpoint'])) {
|
||||
$discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint'];
|
||||
}
|
||||
|
||||
if (!empty($result['token_endpoint'])) {
|
||||
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
|
||||
}
|
||||
|
||||
if (!empty($result['userinfo_endpoint'])) {
|
||||
$discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint'];
|
||||
}
|
||||
|
||||
if (!empty($result['jwks_uri'])) {
|
||||
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
|
||||
$discoveredSettings['keys'] = $this->filterKeys($keys);
|
||||
}
|
||||
|
||||
if (!empty($result['end_session_endpoint'])) {
|
||||
$discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
|
||||
}
|
||||
|
||||
return $discoveredSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the given JWK keys down to just those we support.
|
||||
*/
|
||||
protected function filterKeys(array $keys): array
|
||||
{
|
||||
return array_filter($keys, function (array $key) {
|
||||
$alg = $key['alg'] ?? 'RS256';
|
||||
$use = $key['use'] ?? 'sig';
|
||||
|
||||
return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array of jwks as PHP key=>value arrays.
|
||||
*
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws OidcIssuerDiscoveryException
|
||||
*/
|
||||
protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array
|
||||
{
|
||||
$request = new Request('GET', $uri);
|
||||
$response = $httpClient->sendRequest($request);
|
||||
$result = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if (empty($result) || !is_array($result) || !isset($result['keys'])) {
|
||||
throw new OidcIssuerDiscoveryException('Error reading keys from issuer jwks_uri');
|
||||
}
|
||||
|
||||
return $result['keys'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the settings needed by an OAuth provider, as a key=>value array.
|
||||
*/
|
||||
public function arrayForOAuthProvider(): array
|
||||
{
|
||||
$settingKeys = ['clientId', 'clientSecret', 'authorizationEndpoint', 'tokenEndpoint', 'userinfoEndpoint'];
|
||||
$settings = [];
|
||||
foreach ($settingKeys as $setting) {
|
||||
$settings[$setting] = $this->$setting;
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
use BookStack\Access\GroupSyncService;
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Access\RegistrationService;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Uploads\UserAvatars;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
||||
|
||||
/**
|
||||
* Class OpenIdConnectService
|
||||
* Handles any app-specific OIDC tasks.
|
||||
*/
|
||||
class OidcService
|
||||
{
|
||||
public function __construct(
|
||||
protected RegistrationService $registrationService,
|
||||
protected LoginService $loginService,
|
||||
protected HttpRequestService $http,
|
||||
protected GroupSyncService $groupService,
|
||||
protected UserAvatars $userAvatars
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate an authorization flow.
|
||||
* Provides back an authorize redirect URL, in addition to other
|
||||
* details which may be required for the auth flow.
|
||||
*
|
||||
* @throws OidcException
|
||||
*
|
||||
* @return array{url: string, state: string}
|
||||
*/
|
||||
public function login(): array
|
||||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
$url = $provider->getAuthorizationUrl();
|
||||
session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
|
||||
|
||||
return [
|
||||
'url' => $url,
|
||||
'state' => $provider->getState(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the Authorization response from the authorization server and
|
||||
* return the matching, or new if registration active, user matched to the
|
||||
* authorization server. Throws if the user cannot be auth if not authenticated.
|
||||
*
|
||||
* @throws JsonDebugException
|
||||
* @throws OidcException
|
||||
* @throws StoppedAuthenticationException
|
||||
* @throws IdentityProviderException
|
||||
*/
|
||||
public function processAuthorizeResponse(?string $authorizationCode): User
|
||||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
// Set PKCE code flashed at login
|
||||
$pkceCode = session()->pull('oidc_pkce_code', '');
|
||||
$provider->setPkceCode($pkceCode);
|
||||
|
||||
// Try to exchange authorization code for access token
|
||||
$accessToken = $provider->getAccessToken('authorization_code', [
|
||||
'code' => $authorizationCode,
|
||||
]);
|
||||
|
||||
return $this->processAccessTokenCallback($accessToken, $settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OidcException
|
||||
*/
|
||||
protected function getProviderSettings(): OidcProviderSettings
|
||||
{
|
||||
$config = $this->config();
|
||||
$settings = new OidcProviderSettings([
|
||||
'issuer' => $config['issuer'],
|
||||
'clientId' => $config['client_id'],
|
||||
'clientSecret' => $config['client_secret'],
|
||||
'authorizationEndpoint' => $config['authorization_endpoint'],
|
||||
'tokenEndpoint' => $config['token_endpoint'],
|
||||
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
|
||||
'userinfoEndpoint' => $config['userinfo_endpoint'],
|
||||
]);
|
||||
|
||||
// Use keys if configured
|
||||
if (!empty($config['jwt_public_key'])) {
|
||||
$settings->keys = [$config['jwt_public_key']];
|
||||
}
|
||||
|
||||
// Run discovery
|
||||
if ($config['discover'] ?? false) {
|
||||
try {
|
||||
$settings->discoverFromIssuer($this->http->buildClient(5), Cache::store(null), 15);
|
||||
} catch (OidcIssuerDiscoveryException $exception) {
|
||||
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent use of RP-initiated logout if specifically disabled
|
||||
// Or force use of a URL if specifically set.
|
||||
if ($config['end_session_endpoint'] === false) {
|
||||
$settings->endSessionEndpoint = null;
|
||||
} else if (is_string($config['end_session_endpoint'])) {
|
||||
$settings->endSessionEndpoint = $config['end_session_endpoint'];
|
||||
}
|
||||
|
||||
$settings->validate();
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the underlying OpenID Connect Provider.
|
||||
*/
|
||||
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
|
||||
{
|
||||
$provider = new OidcOAuthProvider([
|
||||
...$settings->arrayForOAuthProvider(),
|
||||
'redirectUri' => url('/oidc/callback'),
|
||||
], [
|
||||
'httpClient' => $this->http->buildClient(5),
|
||||
'optionProvider' => new HttpBasicAuthOptionProvider(),
|
||||
]);
|
||||
|
||||
foreach ($this->getAdditionalScopes() as $scope) {
|
||||
$provider->addScope($scope);
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get any user-defined addition/custom scopes to apply to the authentication request.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
protected function getAdditionalScopes(): array
|
||||
{
|
||||
$scopeConfig = $this->config()['additional_scopes'] ?: '';
|
||||
|
||||
$scopeArr = explode(',', $scopeConfig);
|
||||
$scopeArr = array_map(fn (string $scope) => trim($scope), $scopeArr);
|
||||
|
||||
return array_filter($scopeArr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a received access token for a user. Login the user when
|
||||
* they exist, optionally registering them automatically.
|
||||
*
|
||||
* @throws OidcException
|
||||
* @throws JsonDebugException
|
||||
* @throws StoppedAuthenticationException
|
||||
*/
|
||||
protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
|
||||
{
|
||||
$idTokenText = $accessToken->getIdToken();
|
||||
$idToken = new OidcIdToken(
|
||||
$idTokenText,
|
||||
$settings->issuer,
|
||||
$settings->keys,
|
||||
);
|
||||
|
||||
session()->put("oidc_id_token", $idTokenText);
|
||||
|
||||
$returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [
|
||||
'access_token' => $accessToken->getToken(),
|
||||
'expires_in' => $accessToken->getExpires(),
|
||||
'refresh_token' => $accessToken->getRefreshToken(),
|
||||
]);
|
||||
|
||||
if (!is_null($returnClaims)) {
|
||||
$idToken->replaceClaims($returnClaims);
|
||||
}
|
||||
|
||||
if ($this->config()['dump_user_details']) {
|
||||
throw new JsonDebugException($idToken->getAllClaims());
|
||||
}
|
||||
|
||||
try {
|
||||
$idToken->validate($settings->clientId);
|
||||
} catch (OidcInvalidTokenException $exception) {
|
||||
throw new OidcException("ID token validation failed with error: {$exception->getMessage()}");
|
||||
}
|
||||
|
||||
$userDetails = $this->getUserDetailsFromToken($idToken, $accessToken, $settings);
|
||||
if (empty($userDetails->email)) {
|
||||
throw new OidcException(trans('errors.oidc_no_email_address'));
|
||||
}
|
||||
if (empty($userDetails->name)) {
|
||||
$userDetails->name = $userDetails->externalId;
|
||||
}
|
||||
|
||||
$isLoggedIn = auth()->check();
|
||||
if ($isLoggedIn) {
|
||||
throw new OidcException(trans('errors.oidc_already_logged_in'));
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $this->registrationService->findOrRegister(
|
||||
$userDetails->name,
|
||||
$userDetails->email,
|
||||
$userDetails->externalId
|
||||
);
|
||||
} catch (UserRegistrationException $exception) {
|
||||
throw new OidcException($exception->getMessage());
|
||||
}
|
||||
|
||||
if ($this->config()['fetch_avatar'] && !$user->avatar()->exists() && $userDetails->picture) {
|
||||
$this->userAvatars->assignToUserFromUrl($user, $userDetails->picture);
|
||||
}
|
||||
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$detachExisting = $this->config()['remove_from_groups'];
|
||||
$this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);
|
||||
}
|
||||
|
||||
$this->loginService->login($user, 'oidc');
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OidcException
|
||||
*/
|
||||
protected function getUserDetailsFromToken(OidcIdToken $idToken, OidcAccessToken $accessToken, OidcProviderSettings $settings): OidcUserDetails
|
||||
{
|
||||
$userDetails = new OidcUserDetails();
|
||||
$userDetails->populate(
|
||||
$idToken,
|
||||
$this->config()['external_id_claim'],
|
||||
$this->config()['display_name_claims'] ?? '',
|
||||
$this->config()['groups_claim'] ?? ''
|
||||
);
|
||||
|
||||
if (!$userDetails->isFullyPopulated($this->shouldSyncGroups()) && !empty($settings->userinfoEndpoint)) {
|
||||
$provider = $this->getProvider($settings);
|
||||
$request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken());
|
||||
$response = new OidcUserinfoResponse(
|
||||
$provider->getResponse($request),
|
||||
$settings->issuer,
|
||||
$settings->keys,
|
||||
);
|
||||
|
||||
try {
|
||||
$response->validate($idToken->getClaim('sub'), $settings->clientId);
|
||||
} catch (OidcInvalidTokenException $exception) {
|
||||
throw new OidcException("Userinfo endpoint response validation failed with error: {$exception->getMessage()}");
|
||||
}
|
||||
|
||||
$userDetails->populate(
|
||||
$response,
|
||||
$this->config()['external_id_claim'],
|
||||
$this->config()['display_name_claims'] ?? '',
|
||||
$this->config()['groups_claim'] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
return $userDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OIDC config from the application.
|
||||
*/
|
||||
protected function config(): array
|
||||
{
|
||||
return config('oidc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if groups should be synced.
|
||||
*/
|
||||
protected function shouldSyncGroups(): bool
|
||||
{
|
||||
return $this->config()['user_to_groups'] !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the RP-initiated logout flow if active, otherwise start a standard logout flow.
|
||||
* Returns a post-app-logout redirect URL.
|
||||
* Reference: https://openid.net/specs/openid-connect-rpinitiated-1_0.html
|
||||
* @throws OidcException
|
||||
*/
|
||||
public function logout(): string
|
||||
{
|
||||
$oidcToken = session()->pull("oidc_id_token");
|
||||
$defaultLogoutUrl = url($this->loginService->logout());
|
||||
$oidcSettings = $this->getProviderSettings();
|
||||
|
||||
if (!$oidcSettings->endSessionEndpoint) {
|
||||
return $defaultLogoutUrl;
|
||||
}
|
||||
|
||||
$endpointParams = [
|
||||
'id_token_hint' => $oidcToken,
|
||||
'post_logout_redirect_uri' => $defaultLogoutUrl,
|
||||
];
|
||||
|
||||
$joiner = str_contains($oidcSettings->endSessionEndpoint, '?') ? '&' : '?';
|
||||
|
||||
return $oidcSettings->endSessionEndpoint . $joiner . http_build_query($endpointParams);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class OidcUserDetails
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $externalId = null,
|
||||
public ?string $email = null,
|
||||
public ?string $name = null,
|
||||
public ?array $groups = null,
|
||||
public ?string $picture = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user details are fully populated for our usage.
|
||||
*/
|
||||
public function isFullyPopulated(bool $groupSyncActive): bool
|
||||
{
|
||||
$hasEmpty = empty($this->externalId)
|
||||
|| empty($this->email)
|
||||
|| empty($this->name)
|
||||
|| ($groupSyncActive && $this->groups === null);
|
||||
|
||||
return !$hasEmpty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate user details from the given claim data.
|
||||
*/
|
||||
public function populate(
|
||||
ProvidesClaims $claims,
|
||||
string $idClaim,
|
||||
string $displayNameClaims,
|
||||
string $groupsClaim,
|
||||
): void {
|
||||
$this->externalId = $claims->getClaim($idClaim) ?? $this->externalId;
|
||||
$this->email = $claims->getClaim('email') ?? $this->email;
|
||||
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
|
||||
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
|
||||
$this->picture = static::getPicture($claims) ?: $this->picture;
|
||||
}
|
||||
|
||||
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $claims): string
|
||||
{
|
||||
$displayNameClaimParts = explode('|', $displayNameClaims);
|
||||
|
||||
$displayName = [];
|
||||
foreach ($displayNameClaimParts as $claim) {
|
||||
$component = $claims->getClaim(trim($claim)) ?? '';
|
||||
if ($component !== '') {
|
||||
$displayName[] = $component;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(' ', $displayName);
|
||||
}
|
||||
|
||||
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $claims): ?array
|
||||
{
|
||||
if (empty($groupsClaim)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$groupsList = Arr::get($claims->getAllClaims(), $groupsClaim);
|
||||
if (!is_array($groupsList)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_values(array_filter($groupsList, function ($val) {
|
||||
return is_string($val);
|
||||
}));
|
||||
}
|
||||
|
||||
protected static function getPicture(ProvidesClaims $claims): ?string
|
||||
{
|
||||
$picture = $claims->getClaim('picture');
|
||||
if (is_string($picture) && str_starts_with($picture, 'http')) {
|
||||
return $picture;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class OidcUserinfoResponse implements ProvidesClaims
|
||||
{
|
||||
protected array $claims = [];
|
||||
protected ?OidcJwtWithClaims $jwt = null;
|
||||
|
||||
public function __construct(ResponseInterface $response, string $issuer, array $keys)
|
||||
{
|
||||
$contentTypeHeaderValue = $response->getHeader('Content-Type')[0] ?? '';
|
||||
$contentType = strtolower(trim(explode(';', $contentTypeHeaderValue, 2)[0]));
|
||||
|
||||
if ($contentType === 'application/json') {
|
||||
$this->claims = json_decode($response->getBody()->getContents(), true);
|
||||
}
|
||||
|
||||
if ($contentType === 'application/jwt') {
|
||||
$this->jwt = new OidcJwtWithClaims($response->getBody()->getContents(), $issuer, $keys);
|
||||
$this->claims = $this->jwt->getAllClaims();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
public function validate(string $idTokenSub, string $clientId): bool
|
||||
{
|
||||
if (!is_null($this->jwt)) {
|
||||
$this->jwt->validateCommonTokenDetails($clientId);
|
||||
}
|
||||
|
||||
$sub = $this->getClaim('sub');
|
||||
|
||||
// Spec: v1.0 5.3.2: The sub (subject) Claim MUST always be returned in the UserInfo Response.
|
||||
if (!is_string($sub) || empty($sub)) {
|
||||
throw new OidcInvalidTokenException("No valid subject value found in userinfo data");
|
||||
}
|
||||
|
||||
// Spec: v1.0 5.3.2: The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token;
|
||||
// if they do not match, the UserInfo Response values MUST NOT be used.
|
||||
if ($idTokenSub !== $sub) {
|
||||
throw new OidcInvalidTokenException("Subject value provided in the userinfo endpoint does not match the provided ID token value");
|
||||
}
|
||||
|
||||
// Spec v1.0 5.3.4 Defines the following:
|
||||
// Verify that the OP that responded was the intended OP through a TLS server certificate check, per RFC 6125 [RFC6125].
|
||||
// This is effectively done as part of the HTTP request we're making through CURLOPT_SSL_VERIFYHOST on the request.
|
||||
// If the Client has provided a userinfo_encrypted_response_alg parameter during Registration, decrypt the UserInfo Response using the keys specified during Registration.
|
||||
// We don't currently support JWT encryption for OIDC
|
||||
// If the response was signed, the Client SHOULD validate the signature according to JWS [JWS].
|
||||
// This is done as part of the validateCommonClaims above.
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getClaim(string $claim): mixed
|
||||
{
|
||||
return $this->claims[$claim] ?? null;
|
||||
}
|
||||
|
||||
public function getAllClaims(): array
|
||||
{
|
||||
return $this->claims;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
interface ProvidesClaims
|
||||
{
|
||||
/**
|
||||
* Fetch a specific claim.
|
||||
* Returns null if it is null or does not exist.
|
||||
*/
|
||||
public function getClaim(string $claim): mixed;
|
||||
|
||||
/**
|
||||
* Get all contained claims.
|
||||
*/
|
||||
public function getAllClaims(): array;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Users\Models\User;
|
||||
use BookStack\Users\UserRepo;
|
||||
use Exception;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RegistrationService
|
||||
{
|
||||
public function __construct(
|
||||
protected UserRepo $userRepo,
|
||||
protected EmailConfirmationService $emailConfirmationService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if 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'];
|
||||
$authSystem = $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver();
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
/** @var ?bool $shouldRegister */
|
||||
$shouldRegister = Theme::dispatch(ThemeEvents::AUTH_PRE_REGISTER, $authSystem, $userData);
|
||||
if ($shouldRegister === false) {
|
||||
throw new UserRegistrationException(trans('errors.auth_pre_register_theme_prevention'), '/login');
|
||||
}
|
||||
|
||||
// Create the user
|
||||
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
|
||||
$newUser->attachDefaultRole();
|
||||
|
||||
// Assign social account if given
|
||||
if ($socialAccount) {
|
||||
$newUser->socialAccounts()->save($socialAccount);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
|
||||
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $authSystem, $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 = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\SamlException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Users\Models\User;
|
||||
use Exception;
|
||||
use OneLogin\Saml2\Auth;
|
||||
use OneLogin\Saml2\Constants;
|
||||
use OneLogin\Saml2\Error;
|
||||
use OneLogin\Saml2\IdPMetadataParser;
|
||||
use OneLogin\Saml2\ValidationError;
|
||||
|
||||
/**
|
||||
* Class Saml2Service
|
||||
* Handles any app-specific SAML tasks.
|
||||
*/
|
||||
class Saml2Service
|
||||
{
|
||||
protected array $config;
|
||||
|
||||
public function __construct(
|
||||
protected RegistrationService $registrationService,
|
||||
protected LoginService $loginService,
|
||||
protected GroupSyncService $groupSyncService
|
||||
) {
|
||||
$this->config = config('saml2');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the SAML2 request ID, and the URL to redirect the user to.
|
||||
*
|
||||
* @throws Error
|
||||
* @return array{url: string, id: ?string}
|
||||
*/
|
||||
public function logout(User $user): array
|
||||
{
|
||||
$toolKit = $this->getToolkit();
|
||||
$sessionIndex = session()->get('saml2_session_index');
|
||||
$returnUrl = url($this->loginService->logout());
|
||||
|
||||
try {
|
||||
$url = $toolKit->logout(
|
||||
$returnUrl,
|
||||
[],
|
||||
$user->email,
|
||||
$sessionIndex,
|
||||
true,
|
||||
Constants::NAMEID_EMAIL_ADDRESS
|
||||
);
|
||||
$id = $toolKit->getLastRequestID();
|
||||
} catch (Error $error) {
|
||||
if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) {
|
||||
throw $error;
|
||||
}
|
||||
|
||||
$url = $returnUrl;
|
||||
$id = null;
|
||||
}
|
||||
|
||||
return ['url' => $url, 'id' => $id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the ACS response from the idp and return the
|
||||
* matching, or new if registration active, user matched to the idp.
|
||||
* Returns null if not authenticated.
|
||||
*
|
||||
* @throws Error
|
||||
* @throws SamlException
|
||||
* @throws ValidationError
|
||||
* @throws JsonDebugException
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
public function processAcsResponse(?string $requestId, string $samlResponse): ?User
|
||||
{
|
||||
// The SAML2 toolkit expects the response to be within the $_POST superglobal
|
||||
// so we need to manually put it back there at this point.
|
||||
$_POST['SAMLResponse'] = $samlResponse;
|
||||
$toolkit = $this->getToolkit();
|
||||
$toolkit->processResponse($requestId);
|
||||
$errors = $toolkit->getErrors();
|
||||
|
||||
if (!empty($errors)) {
|
||||
$reason = $toolkit->getLastErrorReason();
|
||||
$message = 'Invalid ACS Response; Errors: ' . implode(', ', $errors);
|
||||
$message .= $reason ? "; Reason: {$reason}" : '';
|
||||
throw new Error($message);
|
||||
}
|
||||
|
||||
if (!$toolkit->isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attrs = $toolkit->getAttributes();
|
||||
$id = $toolkit->getNameId();
|
||||
session()->put('saml2_session_index', $toolkit->getSessionIndex());
|
||||
|
||||
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.
|
||||
/** @var ?string $samlRedirect */
|
||||
$samlRedirect = $toolkit->processSLO(true, $requestId, true, null, true);
|
||||
$errors = $toolkit->getErrors();
|
||||
|
||||
if (!empty($errors)) {
|
||||
throw new Error(
|
||||
'Invalid SLS Response: ' . implode(', ', $errors)
|
||||
);
|
||||
}
|
||||
|
||||
$defaultBookStackRedirect = $this->loginService->logout();
|
||||
|
||||
return $samlRedirect ?? $defaultBookStackRedirect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the metadata for this service provider.
|
||||
*
|
||||
* @throws Error
|
||||
*/
|
||||
public function metadata(): string
|
||||
{
|
||||
$toolKit = $this->getToolkit(true);
|
||||
$settings = $toolKit->getSettings();
|
||||
$metadata = $settings->getSPMetadata();
|
||||
$errors = $settings->validateMetadata($metadata);
|
||||
|
||||
if (!empty($errors)) {
|
||||
throw new Error(
|
||||
'Invalid SP metadata: ' . implode(', ', $errors),
|
||||
Error::METADATA_SP_INVALID
|
||||
);
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the underlying Onelogin SAML2 toolkit.
|
||||
*
|
||||
* @throws Error
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function getToolkit(bool $spOnly = false): Auth
|
||||
{
|
||||
$settings = $this->config['onelogin'];
|
||||
$overrides = $this->config['onelogin_overrides'] ?? [];
|
||||
|
||||
if ($overrides && is_string($overrides)) {
|
||||
$overrides = json_decode($overrides, true);
|
||||
}
|
||||
|
||||
$metaDataSettings = [];
|
||||
if (!$spOnly && $this->config['autoload_from_metadata']) {
|
||||
$metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']);
|
||||
}
|
||||
|
||||
$spSettings = $this->loadOneloginServiceProviderDetails();
|
||||
$settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
|
||||
|
||||
return new Auth($settings, $spOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load dynamic service provider options required by the onelogin toolkit.
|
||||
*/
|
||||
protected function loadOneloginServiceProviderDetails(): array
|
||||
{
|
||||
$spDetails = [
|
||||
'entityId' => url('/saml2/metadata'),
|
||||
'assertionConsumerService' => [
|
||||
'url' => url('/saml2/acs'),
|
||||
],
|
||||
'singleLogoutService' => [
|
||||
'url' => url('/saml2/sls'),
|
||||
],
|
||||
];
|
||||
|
||||
return [
|
||||
'baseurl' => url('/saml2'),
|
||||
'sp' => $spDetails,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if groups should be synced.
|
||||
*/
|
||||
protected function shouldSyncGroups(): bool
|
||||
{
|
||||
return $this->config['user_to_groups'] !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the display name.
|
||||
*/
|
||||
protected function getUserDisplayName(array $samlAttributes, string $defaultValue): string
|
||||
{
|
||||
$displayNameAttr = $this->config['display_name_attributes'];
|
||||
|
||||
$displayName = [];
|
||||
foreach ($displayNameAttr as $dnAttr) {
|
||||
$dnComponent = $this->getSamlResponseAttribute($samlAttributes, $dnAttr, null);
|
||||
if ($dnComponent !== null) {
|
||||
$displayName[] = $dnComponent;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($displayName) == 0) {
|
||||
$displayName = $defaultValue;
|
||||
} else {
|
||||
$displayName = implode(' ', $displayName);
|
||||
}
|
||||
|
||||
return $displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value to use as the external id saved in BookStack
|
||||
* used to link the user to an existing BookStack DB user.
|
||||
*/
|
||||
protected function getExternalId(array $samlAttributes, string $defaultValue)
|
||||
{
|
||||
$userNameAttr = $this->config['external_id_attribute'];
|
||||
if ($userNameAttr === null) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
return $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the details of a user from a SAML response.
|
||||
*
|
||||
* @return array{external_id: string, name: string, email: string, saml_id: string}
|
||||
*/
|
||||
protected function getUserDetails(string $samlID, $samlAttributes): array
|
||||
{
|
||||
$emailAttr = $this->config['email_attribute'];
|
||||
$externalId = $this->getExternalId($samlAttributes, $samlID);
|
||||
|
||||
$defaultEmail = filter_var($samlID, FILTER_VALIDATE_EMAIL) ? $samlID : null;
|
||||
$email = $this->getSamlResponseAttribute($samlAttributes, $emailAttr, $defaultEmail);
|
||||
|
||||
return [
|
||||
'external_id' => $externalId,
|
||||
'name' => $this->getUserDisplayName($samlAttributes, $externalId),
|
||||
'email' => $email,
|
||||
'saml_id' => $samlID,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the groups a user is a part of from the SAML response.
|
||||
*/
|
||||
public function getUserGroups(array $samlAttributes): array
|
||||
{
|
||||
$groupsAttr = $this->config['group_attribute'];
|
||||
$userGroups = $samlAttributes[$groupsAttr] ?? null;
|
||||
|
||||
if (!is_array($userGroups)) {
|
||||
$userGroups = [];
|
||||
}
|
||||
|
||||
return $userGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* For an array of strings, return a default for an empty array,
|
||||
* a string for an array with one element and the full array for
|
||||
* more than one element.
|
||||
*/
|
||||
protected function simplifyValue(array $data, $defaultValue)
|
||||
{
|
||||
switch (count($data)) {
|
||||
case 0:
|
||||
$data = $defaultValue;
|
||||
break;
|
||||
case 1:
|
||||
$data = $data[0];
|
||||
break;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a property from an SAML response.
|
||||
* Handles properties potentially being an array.
|
||||
*/
|
||||
protected function getSamlResponseAttribute(array $samlAttributes, string $propertyKey, $defaultValue)
|
||||
{
|
||||
if (isset($samlAttributes[$propertyKey])) {
|
||||
return $this->simplifyValue($samlAttributes[$propertyKey], $defaultValue);
|
||||
}
|
||||
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the SAML response for a user. Login the user when
|
||||
* they exist, optionally registering them automatically.
|
||||
*
|
||||
* @throws SamlException
|
||||
* @throws JsonDebugException
|
||||
* @throws UserRegistrationException
|
||||
* @throws StoppedAuthenticationException
|
||||
*/
|
||||
public function processLoginCallback(string $samlID, array $samlAttributes): User
|
||||
{
|
||||
$userDetails = $this->getUserDetails($samlID, $samlAttributes);
|
||||
$isLoggedIn = auth()->check();
|
||||
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$userDetails['groups'] = $this->getUserGroups($samlAttributes);
|
||||
}
|
||||
|
||||
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 ($this->shouldSyncGroups()) {
|
||||
$this->groupSyncService->syncUserWithFoundGroups($user, $userDetails['groups'], $this->config['remove_from_groups']);
|
||||
}
|
||||
|
||||
$this->loginService->login($user, 'saml2');
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property string $driver
|
||||
* @property User $user
|
||||
*/
|
||||
class SocialAccount extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['user_id', 'driver', 'driver_id'];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "{$this->driver}; {$this->user->logDescriptor()}";
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Exceptions\SocialDriverNotConfigured;
|
||||
use BookStack\Exceptions\SocialSignInAccountNotUsed;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\Factory as Socialite;
|
||||
use Laravel\Socialite\Contracts\Provider;
|
||||
use Laravel\Socialite\Contracts\User as SocialUser;
|
||||
use Laravel\Socialite\Two\GoogleProvider;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
class SocialAuthService
|
||||
{
|
||||
public function __construct(
|
||||
protected Socialite $socialite,
|
||||
protected LoginService $loginService,
|
||||
protected SocialDriverManager $driverManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the social login path.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
*/
|
||||
public function startLogIn(string $socialDriver): RedirectResponse
|
||||
{
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
$this->driverManager->ensureDriverActive($socialDriver);
|
||||
|
||||
return $this->getDriverForRedirect($socialDriver)->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the social registration process.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
*/
|
||||
public function startRegister(string $socialDriver): RedirectResponse
|
||||
{
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
$this->driverManager->ensureDriverActive($socialDriver);
|
||||
|
||||
return $this->getDriverForRedirect($socialDriver)->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the social registration process on callback.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser): SocialUser
|
||||
{
|
||||
// Check social account has not already been used
|
||||
if (SocialAccount::query()->where('driver_id', '=', $socialUser->getId())->exists()) {
|
||||
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount' => $socialDriver]), '/login');
|
||||
}
|
||||
|
||||
if (User::query()->where('email', '=', $socialUser->getEmail())->exists()) {
|
||||
$email = $socialUser->getEmail();
|
||||
|
||||
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $email]), '/login');
|
||||
}
|
||||
|
||||
return $socialUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the social user details via the social driver.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
*/
|
||||
public function getSocialUser(string $socialDriver): SocialUser
|
||||
{
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
$this->driverManager->ensureDriverActive($socialDriver);
|
||||
|
||||
return $this->socialite->driver($socialDriver)->user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the login process on a oAuth callback.
|
||||
*
|
||||
* @throws SocialSignInAccountNotUsed
|
||||
*/
|
||||
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
|
||||
{
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
$socialId = $socialUser->getId();
|
||||
|
||||
// Get any attached social accounts or users
|
||||
$socialAccount = SocialAccount::query()->where('driver_id', '=', $socialId)->first();
|
||||
$isLoggedIn = auth()->check();
|
||||
$currentUser = user();
|
||||
$titleCaseDriver = Str::title($socialDriver);
|
||||
|
||||
// When a user is not logged in and a matching SocialAccount exists,
|
||||
// Simply log the user into the application.
|
||||
if (!$isLoggedIn && $socialAccount !== null) {
|
||||
$this->loginService->login($socialAccount->user, $socialDriver);
|
||||
|
||||
return redirect()->intended('/');
|
||||
}
|
||||
|
||||
// When a user is logged in but the social account does not exist,
|
||||
// Create the social account and attach it to the user & redirect to the profile page.
|
||||
if ($isLoggedIn && $socialAccount === null) {
|
||||
$account = $this->newSocialAccount($socialDriver, $socialUser);
|
||||
$currentUser->socialAccounts()->save($account);
|
||||
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver]));
|
||||
|
||||
return redirect('/my-account/auth#social_accounts');
|
||||
}
|
||||
|
||||
// When a user is logged in and the social account exists and is already linked to the current user.
|
||||
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
|
||||
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver]));
|
||||
|
||||
return redirect('/my-account/auth#social_accounts');
|
||||
}
|
||||
|
||||
// When a user is logged in, A social account exists but the users do not match.
|
||||
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
|
||||
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver]));
|
||||
|
||||
return redirect('/my-account/auth#social_accounts');
|
||||
}
|
||||
|
||||
// Otherwise let the user know this social account is not used by anyone.
|
||||
$message = trans('errors.social_account_not_used', ['socialAccount' => $titleCaseDriver]);
|
||||
if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') {
|
||||
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]);
|
||||
}
|
||||
|
||||
throw new SocialSignInAccountNotUsed($message, '/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the social driver manager used by this service.
|
||||
*/
|
||||
public function drivers(): SocialDriverManager
|
||||
{
|
||||
return $this->driverManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill and return a SocialAccount from the given driver name and SocialUser.
|
||||
*/
|
||||
public function newSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
|
||||
{
|
||||
return new SocialAccount([
|
||||
'driver' => $socialDriver,
|
||||
'driver_id' => $socialUser->getId(),
|
||||
'avatar' => $socialUser->getAvatar(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach a social account from a user.
|
||||
*/
|
||||
public function detachSocialAccount(string $socialDriver): void
|
||||
{
|
||||
user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide redirect options per service for the Laravel Socialite driver.
|
||||
*/
|
||||
protected function getDriverForRedirect(string $driverName): Provider
|
||||
{
|
||||
$driver = $this->socialite->driver($driverName);
|
||||
|
||||
if ($driver instanceof GoogleProvider && config('services.google.select_account')) {
|
||||
$driver->with(['prompt' => 'select_account']);
|
||||
}
|
||||
|
||||
$this->driverManager->getConfigureForRedirectCallback($driverName)($driver);
|
||||
|
||||
return $driver;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Exceptions\SocialDriverNotConfigured;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||
|
||||
class SocialDriverManager
|
||||
{
|
||||
/**
|
||||
* The default built-in social drivers we support.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $validDrivers = [
|
||||
'google',
|
||||
'github',
|
||||
'facebook',
|
||||
'slack',
|
||||
'twitter',
|
||||
'azure',
|
||||
'okta',
|
||||
'gitlab',
|
||||
'twitch',
|
||||
'discord',
|
||||
];
|
||||
|
||||
/**
|
||||
* Callbacks to run when configuring a social driver
|
||||
* for an initial redirect action.
|
||||
* Array is keyed by social driver name.
|
||||
* Callbacks are passed an instance of the driver.
|
||||
*
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
protected array $configureForRedirectCallbacks = [];
|
||||
|
||||
/**
|
||||
* Check if the current config for the given driver allows auto-registration.
|
||||
*/
|
||||
public function isAutoRegisterEnabled(string $driver): bool
|
||||
{
|
||||
return $this->getDriverConfigProperty($driver, 'auto_register') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current config for the given driver allow email address auto-confirmation.
|
||||
*/
|
||||
public function isAutoConfirmEmailEnabled(string $driver): bool
|
||||
{
|
||||
return $this->getDriverConfigProperty($driver, 'auto_confirm') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the names of the active social drivers, keyed by driver id.
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getActive(): array
|
||||
{
|
||||
$activeDrivers = [];
|
||||
|
||||
foreach ($this->validDrivers as $driverKey) {
|
||||
if ($this->checkDriverConfigured($driverKey)) {
|
||||
$activeDrivers[$driverKey] = $this->getName($driverKey);
|
||||
}
|
||||
}
|
||||
|
||||
return $activeDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configure-for-redirect callback for the given driver.
|
||||
* This is a callable that allows modification of the driver at redirect time.
|
||||
* Commonly used to perform custom dynamic configuration where required.
|
||||
* The callback is passed a \Laravel\Socialite\Contracts\Provider instance.
|
||||
*/
|
||||
public function getConfigureForRedirectCallback(string $driver): callable
|
||||
{
|
||||
return $this->configureForRedirectCallbacks[$driver] ?? (fn() => true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom socialite driver to be used.
|
||||
* Driver name should be lower_snake_case.
|
||||
* Config array should mirror the structure of a service
|
||||
* within the `Config/services.php` file.
|
||||
* Handler should be a Class@method handler to the SocialiteWasCalled event.
|
||||
*/
|
||||
public function addSocialDriver(
|
||||
string $driverName,
|
||||
array $config,
|
||||
string $socialiteHandler,
|
||||
?callable $configureForRedirect = null
|
||||
) {
|
||||
$this->validDrivers[] = $driverName;
|
||||
config()->set('services.' . $driverName, $config);
|
||||
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
|
||||
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
|
||||
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
|
||||
if (!is_null($configureForRedirect)) {
|
||||
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the presentational name for a driver.
|
||||
*/
|
||||
protected function getName(string $driver): string
|
||||
{
|
||||
return $this->getDriverConfigProperty($driver, 'name') ?? '';
|
||||
}
|
||||
|
||||
protected function getDriverConfigProperty(string $driver, string $property): mixed
|
||||
{
|
||||
return config("services.{$driver}.{$property}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the social driver is correct and supported.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
*/
|
||||
public function ensureDriverActive(string $driverName): void
|
||||
{
|
||||
if (!in_array($driverName, $this->validDrivers)) {
|
||||
abort(404, trans('errors.social_driver_not_found'));
|
||||
}
|
||||
|
||||
if (!$this->checkDriverConfigured($driverName)) {
|
||||
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($driverName)]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a social driver has been configured correctly.
|
||||
*/
|
||||
protected function checkDriverConfigured(string $driver): bool
|
||||
{
|
||||
$lowerName = strtolower($driver);
|
||||
$configPrefix = 'services.' . $lowerName . '.';
|
||||
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
|
||||
|
||||
return !in_array(false, $config) && !in_array(null, $config);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use Exception;
|
||||
|
||||
class UserInviteException extends Exception
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Access\Notifications\UserInviteNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
class UserInviteService extends UserTokenService
|
||||
{
|
||||
protected string $tokenTable = 'user_invites';
|
||||
protected int $expiryTime = 336; // Two weeks
|
||||
|
||||
/**
|
||||
* Send an invitation to a user to sign into BookStack
|
||||
* Removes existing invitation tokens.
|
||||
* @throws UserInviteException
|
||||
*/
|
||||
public function sendInvitation(User $user)
|
||||
{
|
||||
$this->deleteByUser($user);
|
||||
$token = $this->createTokenForUser($user);
|
||||
|
||||
try {
|
||||
$user->notify(new UserInviteNotification($token));
|
||||
} catch (\Exception $exception) {
|
||||
throw new UserInviteException($exception->getMessage(), $exception->getCode(), $exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Exceptions\UserTokenExpiredException;
|
||||
use BookStack\Exceptions\UserTokenNotFoundException;
|
||||
use BookStack\Users\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use stdClass;
|
||||
|
||||
class UserTokenService
|
||||
{
|
||||
/**
|
||||
* Name of table where user tokens are stored.
|
||||
*/
|
||||
protected string $tokenTable = 'user_tokens';
|
||||
|
||||
/**
|
||||
* Token expiry time in hours.
|
||||
*/
|
||||
protected int $expiryTime = 24;
|
||||
|
||||
/**
|
||||
* Delete all tokens that belong to a user.
|
||||
*/
|
||||
public function deleteByUser(User $user): void
|
||||
{
|
||||
DB::table($this->tokenTable)
|
||||
->where('user_id', '=', $user->id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user id from a token, while checking the token exists and has not expired.
|
||||
*
|
||||
* @throws UserTokenNotFoundException
|
||||
* @throws UserTokenExpiredException
|
||||
*/
|
||||
public function checkTokenAndGetUserId(string $token): int
|
||||
{
|
||||
$entry = $this->getEntryByToken($token);
|
||||
|
||||
if (is_null($entry)) {
|
||||
throw new UserTokenNotFoundException('Token "' . $token . '" not found');
|
||||
}
|
||||
|
||||
if ($this->entryExpired($entry)) {
|
||||
throw new UserTokenExpiredException("Token of id {$entry->id} has expired.", $entry->user_id);
|
||||
}
|
||||
|
||||
return $entry->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unique token within the email confirmation database.
|
||||
*/
|
||||
protected function generateToken(): string
|
||||
{
|
||||
$token = Str::random(24);
|
||||
while ($this->tokenExists($token)) {
|
||||
$token = Str::random(25);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and store a token for the given user.
|
||||
*/
|
||||
protected function createTokenForUser(User $user): string
|
||||
{
|
||||
$token = $this->generateToken();
|
||||
DB::table($this->tokenTable)->insert([
|
||||
'user_id' => $user->id,
|
||||
'token' => $token,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given token exists.
|
||||
*/
|
||||
protected function tokenExists(string $token): bool
|
||||
{
|
||||
return DB::table($this->tokenTable)
|
||||
->where('token', '=', $token)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a token entry for the given token.
|
||||
*/
|
||||
protected function getEntryByToken(string $token): ?stdClass
|
||||
{
|
||||
return DB::table($this->tokenTable)
|
||||
->where('token', '=', $token)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given token entry has expired.
|
||||
*/
|
||||
protected function entryExpired(stdClass $tokenEntry): bool
|
||||
{
|
||||
return Carbon::now()->subHours($this->expiryTime)
|
||||
->gt(new Carbon($tokenEntry->created_at));
|
||||
}
|
||||
}
|
||||
55
app/Actions/Activity.php
Normal file
55
app/Actions/Activity.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Model;
|
||||
|
||||
/**
|
||||
* @property string key
|
||||
* @property \User user
|
||||
* @property \Entity entity
|
||||
* @property string extra
|
||||
*/
|
||||
class Activity extends Model
|
||||
{
|
||||
|
||||
/**
|
||||
* Get the entity for this activity.
|
||||
*/
|
||||
public function entity()
|
||||
{
|
||||
if ($this->entity_type === '') {
|
||||
$this->entity_type = null;
|
||||
}
|
||||
return $this->morphTo('entity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user this activity relates to.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns text from the language files, Looks up by using the
|
||||
* activity key.
|
||||
*/
|
||||
public function getText()
|
||||
{
|
||||
return trans('activities.' . $this->key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if another Activity matches the general information of another.
|
||||
* @param $activityB
|
||||
* @return bool
|
||||
*/
|
||||
public function isSimilarTo($activityB)
|
||||
{
|
||||
return [$this->key, $this->entity_type, $this->entity_id] === [$activityB->key, $activityB->entity_type, $activityB->entity_id];
|
||||
}
|
||||
}
|
||||
173
app/Actions/ActivityService.php
Normal file
173
app/Actions/ActivityService.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?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 = 0)
|
||||
{
|
||||
if ($entity->isA('book')) {
|
||||
$query = $this->activity->where('book_id', '=', $entity->id);
|
||||
} else {
|
||||
$query = $this->activity->where('entity_type', '=', get_class($entity))
|
||||
->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)->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
app/Actions/Comment.php
Normal file
45
app/Actions/Comment.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Ownable;
|
||||
|
||||
class Comment extends Ownable
|
||||
{
|
||||
protected $fillable = ['text', 'html', 'parent_id'];
|
||||
protected $appends = ['created', 'updated'];
|
||||
|
||||
/**
|
||||
* Get the entity that this comment belongs to
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
*/
|
||||
public function entity()
|
||||
{
|
||||
return $this->morphTo('entity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a comment has been updated since creation.
|
||||
* @return bool
|
||||
*/
|
||||
public function isUpdated()
|
||||
{
|
||||
return $this->updated_at->timestamp > $this->created_at->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get created date as a relative diff.
|
||||
* @return mixed
|
||||
*/
|
||||
public function getCreatedAttribute()
|
||||
{
|
||||
return $this->created_at->diffForHumans();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get updated date as a relative diff.
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUpdatedAttribute()
|
||||
{
|
||||
return $this->updated_at->diffForHumans();
|
||||
}
|
||||
}
|
||||
89
app/Actions/CommentRepo.php
Normal file
89
app/Actions/CommentRepo.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Entities\Entity;
|
||||
|
||||
/**
|
||||
* Class CommentRepo
|
||||
* @package BookStack\Repos
|
||||
*/
|
||||
class CommentRepo
|
||||
{
|
||||
|
||||
/**
|
||||
* @var \BookStack\Actions\Comment $comment
|
||||
*/
|
||||
protected $comment;
|
||||
|
||||
/**
|
||||
* CommentRepo constructor.
|
||||
* @param \BookStack\Actions\Comment $comment
|
||||
*/
|
||||
public function __construct(Comment $comment)
|
||||
{
|
||||
$this->comment = $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a comment by ID.
|
||||
* @param $id
|
||||
* @return \BookStack\Actions\Comment|\Illuminate\Database\Eloquent\Model
|
||||
*/
|
||||
public function getById($id)
|
||||
{
|
||||
return $this->comment->newQuery()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment on an entity.
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @param array $data
|
||||
* @return \BookStack\Actions\Comment
|
||||
*/
|
||||
public function create(Entity $entity, $data = [])
|
||||
{
|
||||
$userId = user()->id;
|
||||
$comment = $this->comment->newInstance($data);
|
||||
$comment->created_by = $userId;
|
||||
$comment->updated_by = $userId;
|
||||
$comment->local_id = $this->getNextLocalId($entity);
|
||||
$entity->comments()->save($comment);
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing comment.
|
||||
* @param \BookStack\Actions\Comment $comment
|
||||
* @param array $input
|
||||
* @return mixed
|
||||
*/
|
||||
public function update($comment, $input)
|
||||
{
|
||||
$comment->updated_by = user()->id;
|
||||
$comment->update($input);
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment from the system.
|
||||
* @param \BookStack\Actions\Comment $comment
|
||||
* @return mixed
|
||||
*/
|
||||
public function delete($comment)
|
||||
{
|
||||
return $comment->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next local ID relative to the linked entity.
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @return int
|
||||
*/
|
||||
protected function getNextLocalId(Entity $entity)
|
||||
{
|
||||
$comments = $entity->comments(false)->orderBy('local_id', 'desc')->first();
|
||||
if ($comments === null) {
|
||||
return 1;
|
||||
}
|
||||
return $comments->local_id + 1;
|
||||
}
|
||||
}
|
||||
21
app/Actions/Tag.php
Normal file
21
app/Actions/Tag.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Model;
|
||||
|
||||
/**
|
||||
* Class Attribute
|
||||
* @package BookStack
|
||||
*/
|
||||
class Tag extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'value', 'order'];
|
||||
|
||||
/**
|
||||
* Get the entity that this tag belongs to
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
*/
|
||||
public function entity()
|
||||
{
|
||||
return $this->morphTo('entity');
|
||||
}
|
||||
}
|
||||
140
app/Actions/TagRepo.php
Normal file
140
app/Actions/TagRepo.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\Entity;
|
||||
|
||||
/**
|
||||
* Class TagRepo
|
||||
* @package BookStack\Repos
|
||||
*/
|
||||
class TagRepo
|
||||
{
|
||||
|
||||
protected $tag;
|
||||
protected $entity;
|
||||
protected $permissionService;
|
||||
|
||||
/**
|
||||
* TagRepo constructor.
|
||||
* @param \BookStack\Actions\Tag $attr
|
||||
* @param \BookStack\Entities\Entity $ent
|
||||
* @param \BookStack\Auth\Permissions\PermissionService $ps
|
||||
*/
|
||||
public function __construct(Tag $attr, Entity $ent, PermissionService $ps)
|
||||
{
|
||||
$this->tag = $attr;
|
||||
$this->entity = $ent;
|
||||
$this->permissionService = $ps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an entity instance of its particular type.
|
||||
* @param $entityType
|
||||
* @param $entityId
|
||||
* @param string $action
|
||||
* @return \Illuminate\Database\Eloquent\Model|null|static
|
||||
*/
|
||||
public function getEntity($entityType, $entityId, $action = 'view')
|
||||
{
|
||||
$entityInstance = $this->entity->getEntityInstance($entityType);
|
||||
$searchQuery = $entityInstance->where('id', '=', $entityId)->with('tags');
|
||||
$searchQuery = $this->permissionService->enforceEntityRestrictions($entityType, $searchQuery, $action);
|
||||
return $searchQuery->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags for a particular entity.
|
||||
* @param string $entityType
|
||||
* @param int $entityId
|
||||
* @return mixed
|
||||
*/
|
||||
public function getForEntity($entityType, $entityId)
|
||||
{
|
||||
$entity = $this->getEntity($entityType, $entityId);
|
||||
if ($entity === null) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $entity->tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag name suggestions from scanning existing tag names.
|
||||
* If no search term is given the 50 most popular tag names are provided.
|
||||
* @param $searchTerm
|
||||
* @return array
|
||||
*/
|
||||
public function getNameSuggestions($searchTerm = false)
|
||||
{
|
||||
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name');
|
||||
|
||||
if ($searchTerm) {
|
||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
|
||||
} else {
|
||||
$query = $query->orderBy('count', 'desc')->take(50);
|
||||
}
|
||||
|
||||
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||
return $query->get(['name'])->pluck('name');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag value suggestions from scanning existing tag values.
|
||||
* If no search is given the 50 most popular values are provided.
|
||||
* Passing a tagName will only find values for a tags with a particular name.
|
||||
* @param $searchTerm
|
||||
* @param $tagName
|
||||
* @return array
|
||||
*/
|
||||
public function getValueSuggestions($searchTerm = false, $tagName = false)
|
||||
{
|
||||
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value');
|
||||
|
||||
if ($searchTerm) {
|
||||
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
|
||||
} else {
|
||||
$query = $query->orderBy('count', 'desc')->take(50);
|
||||
}
|
||||
|
||||
if ($tagName !== false) {
|
||||
$query = $query->where('name', '=', $tagName);
|
||||
}
|
||||
|
||||
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||
return $query->get(['value'])->pluck('value');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an array of tags to an entity
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @param array $tags
|
||||
* @return array|\Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function saveTagsToEntity(Entity $entity, $tags = [])
|
||||
{
|
||||
$entity->tags()->delete();
|
||||
$newTags = [];
|
||||
foreach ($tags as $tag) {
|
||||
if (trim($tag['name']) === '') {
|
||||
continue;
|
||||
}
|
||||
$newTags[] = $this->newInstanceFromInput($tag);
|
||||
}
|
||||
|
||||
return $entity->tags()->saveMany($newTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Tag instance from user input.
|
||||
* @param $input
|
||||
* @return \BookStack\Actions\Tag
|
||||
*/
|
||||
protected function newInstanceFromInput($input)
|
||||
{
|
||||
$name = trim($input['name']);
|
||||
$value = isset($input['value']) ? trim($input['value']) : '';
|
||||
// Any other modification or cleanup required can go here
|
||||
$values = ['name' => $name, 'value' => $value];
|
||||
return $this->tag->newInstance($values);
|
||||
}
|
||||
}
|
||||
18
app/Actions/View.php
Normal file
18
app/Actions/View.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Model;
|
||||
|
||||
class View extends Model
|
||||
{
|
||||
|
||||
protected $fillable = ['user_id', 'views'];
|
||||
|
||||
/**
|
||||
* Get all owning viewable models.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
*/
|
||||
public function viewable()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
109
app/Actions/ViewService.php
Normal file
109
app/Actions/ViewService.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\Entity;
|
||||
|
||||
class ViewService
|
||||
{
|
||||
protected $view;
|
||||
protected $permissionService;
|
||||
|
||||
/**
|
||||
* ViewService constructor.
|
||||
* @param \BookStack\Actions\View $view
|
||||
* @param \BookStack\Auth\Permissions\PermissionService $permissionService
|
||||
*/
|
||||
public function __construct(View $view, PermissionService $permissionService)
|
||||
{
|
||||
$this->view = $view;
|
||||
$this->permissionService = $permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Entity|false|array $filterModel
|
||||
* @param string $action - used for permission checking
|
||||
* @return
|
||||
*/
|
||||
public function getPopular($count = 10, $page = 0, $filterModel = false, $action = 'view')
|
||||
{
|
||||
// TODO - Standardise input filter
|
||||
$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 ($filterModel && is_array($filterModel)) {
|
||||
$query->whereIn('viewable_type', $filterModel);
|
||||
} else if ($filterModel) {
|
||||
$query->where('viewable_type', '=', $filterModel->getMorphClass());
|
||||
}
|
||||
|
||||
return $query->with('viewable')->skip($skipCount)->take($count)->get()->pluck('viewable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recently viewed entities for the current user.
|
||||
* @param int $count
|
||||
* @param int $page
|
||||
* @param Entity|bool $filterModel
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUserRecentlyViewed($count = 10, $page = 0, $filterModel = false)
|
||||
{
|
||||
$user = user();
|
||||
if ($user === null || $user->isDefault()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$query = $this->permissionService
|
||||
->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type');
|
||||
|
||||
if ($filterModel) {
|
||||
$query = $query->where('viewable_type', '=', $filterModel->getMorphClass());
|
||||
}
|
||||
$query = $query->where('user_id', '=', $user->id);
|
||||
|
||||
$viewables = $query->with('viewable')->orderBy('updated_at', 'desc')
|
||||
->skip($count * $page)->take($count)->get()->pluck('viewable');
|
||||
return $viewables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all view counts by deleting all views.
|
||||
*/
|
||||
public function resetAll()
|
||||
{
|
||||
$this->view->truncate();
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity;
|
||||
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class ActivityQueries
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionApplicator $permissions,
|
||||
protected MixedEntityListLoader $listLoader,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest activity.
|
||||
*/
|
||||
public function latest(int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->with(['user'])
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
$this->listLoader->loadIntoRelations($activityList->all(), 'loggable', false);
|
||||
|
||||
return $this->filterSimilar($activityList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest activity for an entity, Filtering out similar
|
||||
* items to prevent a message activity list.
|
||||
*/
|
||||
public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
|
||||
{
|
||||
/** @var array<string, int[]> $queryIds */
|
||||
$queryIds = [$entity->getMorphClass() => [$entity->id]];
|
||||
|
||||
if ($entity instanceof Book) {
|
||||
$queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->scopes('visible')->pluck('id');
|
||||
}
|
||||
if ($entity instanceof Book || $entity instanceof Chapter) {
|
||||
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
|
||||
}
|
||||
|
||||
$query = Activity::query();
|
||||
$query->where(function (Builder $query) use ($queryIds) {
|
||||
foreach ($queryIds as $morphClass => $idArr) {
|
||||
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
|
||||
$innerQuery->where('loggable_type', '=', $morphClass)
|
||||
->whereIn('loggable_id', $idArr);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$activity = $query->orderBy('created_at', 'desc')
|
||||
->with(['loggable' => function (Relation $query) {
|
||||
/** @var MorphTo<Entity, Activity> $query */
|
||||
$query->withTrashed();
|
||||
}, 'user.avatar'])
|
||||
->skip($count * ($page - 1))
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
return $this->filterSimilar($activity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest activity for a user, Filtering out similar items.
|
||||
*/
|
||||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->where('user_id', '=', $user->id)
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
return $this->filterSimilar($activityList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out similar activity.
|
||||
*
|
||||
* @param Activity[] $activities
|
||||
*/
|
||||
protected function filterSimilar(iterable $activities): array
|
||||
{
|
||||
$newActivity = [];
|
||||
$previousItem = null;
|
||||
|
||||
foreach ($activities as $activityItem) {
|
||||
if (!$previousItem || !$activityItem->isSimilarTo($previousItem)) {
|
||||
$newActivity[] = $activityItem;
|
||||
}
|
||||
|
||||
$previousItem = $activityItem;
|
||||
}
|
||||
|
||||
return $newActivity;
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity;
|
||||
|
||||
class ActivityType
|
||||
{
|
||||
const PAGE_CREATE = 'page_create';
|
||||
const PAGE_UPDATE = 'page_update';
|
||||
const PAGE_DELETE = 'page_delete';
|
||||
const PAGE_RESTORE = 'page_restore';
|
||||
const PAGE_MOVE = 'page_move';
|
||||
|
||||
const CHAPTER_CREATE = 'chapter_create';
|
||||
const CHAPTER_UPDATE = 'chapter_update';
|
||||
const CHAPTER_DELETE = 'chapter_delete';
|
||||
const CHAPTER_MOVE = 'chapter_move';
|
||||
|
||||
const BOOK_CREATE = 'book_create';
|
||||
const BOOK_CREATE_FROM_CHAPTER = 'book_create_from_chapter';
|
||||
const BOOK_UPDATE = 'book_update';
|
||||
const BOOK_DELETE = 'book_delete';
|
||||
const BOOK_SORT = 'book_sort';
|
||||
|
||||
const BOOKSHELF_CREATE = 'bookshelf_create';
|
||||
const BOOKSHELF_CREATE_FROM_BOOK = 'bookshelf_create_from_book';
|
||||
const BOOKSHELF_UPDATE = 'bookshelf_update';
|
||||
const BOOKSHELF_DELETE = 'bookshelf_delete';
|
||||
|
||||
const COMMENTED_ON = 'commented_on';
|
||||
const COMMENT_CREATE = 'comment_create';
|
||||
const COMMENT_UPDATE = 'comment_update';
|
||||
const COMMENT_DELETE = 'comment_delete';
|
||||
|
||||
const PERMISSIONS_UPDATE = 'permissions_update';
|
||||
|
||||
const REVISION_RESTORE = 'revision_restore';
|
||||
const REVISION_DELETE = 'revision_delete';
|
||||
|
||||
const SETTINGS_UPDATE = 'settings_update';
|
||||
const MAINTENANCE_ACTION_RUN = 'maintenance_action_run';
|
||||
|
||||
const RECYCLE_BIN_EMPTY = 'recycle_bin_empty';
|
||||
const RECYCLE_BIN_RESTORE = 'recycle_bin_restore';
|
||||
const RECYCLE_BIN_DESTROY = 'recycle_bin_destroy';
|
||||
|
||||
const USER_CREATE = 'user_create';
|
||||
const USER_UPDATE = 'user_update';
|
||||
const USER_DELETE = 'user_delete';
|
||||
|
||||
const API_TOKEN_CREATE = 'api_token_create';
|
||||
const API_TOKEN_UPDATE = 'api_token_update';
|
||||
const API_TOKEN_DELETE = 'api_token_delete';
|
||||
|
||||
const ROLE_CREATE = 'role_create';
|
||||
const ROLE_UPDATE = 'role_update';
|
||||
const ROLE_DELETE = 'role_delete';
|
||||
|
||||
const AUTH_PASSWORD_RESET = 'auth_password_reset_request';
|
||||
const AUTH_PASSWORD_RESET_UPDATE = 'auth_password_reset_update';
|
||||
const AUTH_LOGIN = 'auth_login';
|
||||
const AUTH_REGISTER = 'auth_register';
|
||||
|
||||
const MFA_SETUP_METHOD = 'mfa_setup_method';
|
||||
const MFA_REMOVE_METHOD = 'mfa_remove_method';
|
||||
|
||||
const WEBHOOK_CREATE = 'webhook_create';
|
||||
const WEBHOOK_UPDATE = 'webhook_update';
|
||||
const WEBHOOK_DELETE = 'webhook_delete';
|
||||
|
||||
const IMPORT_CREATE = 'import_create';
|
||||
const IMPORT_RUN = 'import_run';
|
||||
const IMPORT_DELETE = 'import_delete';
|
||||
|
||||
const SORT_RULE_CREATE = 'sort_rule_create';
|
||||
const SORT_RULE_UPDATE = 'sort_rule_update';
|
||||
const SORT_RULE_DELETE = 'sort_rule_delete';
|
||||
|
||||
/**
|
||||
* Get all the possible values.
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return (new \ReflectionClass(static::class))->getConstants();
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity;
|
||||
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Facades\Activity as ActivityService;
|
||||
use BookStack\Util\HtmlDescriptionFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class CommentRepo
|
||||
{
|
||||
/**
|
||||
* Get a comment by ID.
|
||||
*/
|
||||
public function getById(int $id): Comment
|
||||
{
|
||||
return Comment::query()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a comment by ID, ensuring it is visible to the user based upon access to the page
|
||||
* which the comment is attached to.
|
||||
*/
|
||||
public function getVisibleById(int $id): Comment
|
||||
{
|
||||
return $this->getQueryForVisible()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query for comments visible to the user.
|
||||
* @return Builder<Comment>
|
||||
*/
|
||||
public function getQueryForVisible(): Builder
|
||||
{
|
||||
return Comment::query()->scopes('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment on an entity.
|
||||
*/
|
||||
public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
|
||||
{
|
||||
// Prevent comments being added to draft pages
|
||||
if ($entity instanceof Page && $entity->draft) {
|
||||
throw new \Exception(trans('errors.cannot_add_comment_to_draft'));
|
||||
}
|
||||
|
||||
// Validate parent ID
|
||||
if ($parentId !== null) {
|
||||
$parentCommentExists = Comment::query()
|
||||
->where('commentable_id', '=', $entity->id)
|
||||
->where('commentable_type', '=', $entity->getMorphClass())
|
||||
->where('local_id', '=', $parentId)
|
||||
->exists();
|
||||
if (!$parentCommentExists) {
|
||||
$parentId = null;
|
||||
}
|
||||
}
|
||||
|
||||
$userId = user()->id;
|
||||
$comment = new Comment();
|
||||
|
||||
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||
$comment->created_by = $userId;
|
||||
$comment->updated_by = $userId;
|
||||
$comment->local_id = $this->getNextLocalId($entity);
|
||||
$comment->parent_id = $parentId;
|
||||
$comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $contentRef) === 1 ? $contentRef : '';
|
||||
|
||||
$entity->comments()->save($comment);
|
||||
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
|
||||
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
||||
|
||||
$comment->refresh()->unsetRelations();
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing comment.
|
||||
*/
|
||||
public function update(Comment $comment, string $html): Comment
|
||||
{
|
||||
$comment->updated_by = user()->id;
|
||||
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||
$comment->save();
|
||||
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Archive an existing comment.
|
||||
*/
|
||||
public function archive(Comment $comment, bool $log = true): Comment
|
||||
{
|
||||
if ($comment->parent_id) {
|
||||
throw new NotifyException('Only top-level comments can be archived.', '/', 400);
|
||||
}
|
||||
|
||||
$comment->archived = true;
|
||||
$comment->save();
|
||||
|
||||
if ($log) {
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
}
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-archive an existing comment.
|
||||
*/
|
||||
public function unarchive(Comment $comment, bool $log = true): Comment
|
||||
{
|
||||
if ($comment->parent_id) {
|
||||
throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
|
||||
}
|
||||
|
||||
$comment->archived = false;
|
||||
$comment->save();
|
||||
|
||||
if ($log) {
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
}
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment from the system.
|
||||
*/
|
||||
public function delete(Comment $comment): void
|
||||
{
|
||||
$comment->delete();
|
||||
|
||||
ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next local ID relative to the linked entity.
|
||||
*/
|
||||
protected function getNextLocalId(Entity $entity): int
|
||||
{
|
||||
$currentMaxId = $entity->comments()->max('local_id');
|
||||
|
||||
return $currentMaxId + 1;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Permissions\Permission;
|
||||
|
||||
class AuditLogApiController extends ApiController
|
||||
{
|
||||
/**
|
||||
* Get a listing of audit log events in the system.
|
||||
* The loggable relation fields currently only relates to core
|
||||
* content types (page, book, bookshelf, chapter) but this may be
|
||||
* used more in the future across other types.
|
||||
* Requires permission to manage both users and system settings.
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$this->checkPermission(Permission::SettingsManage);
|
||||
$this->checkPermission(Permission::UsersManage);
|
||||
|
||||
$query = Activity::query()->with(['user']);
|
||||
|
||||
return $this->apiListingResponse($query, [
|
||||
'id', 'type', 'detail', 'user_id', 'loggable_id', 'loggable_type', 'ip', 'created_at',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Sorting\SortUrl;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AuditLogController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->checkPermission(Permission::SettingsManage);
|
||||
$this->checkPermission(Permission::UsersManage);
|
||||
|
||||
$sort = $request->get('sort', 'activity_date');
|
||||
$order = $request->get('order', 'desc');
|
||||
$listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
|
||||
'created_at' => trans('settings.audit_table_date'),
|
||||
'type' => trans('settings.audit_table_event'),
|
||||
]);
|
||||
|
||||
$filters = [
|
||||
'event' => $request->get('event', ''),
|
||||
'date_from' => $request->get('date_from', ''),
|
||||
'date_to' => $request->get('date_to', ''),
|
||||
'user' => $request->get('user', ''),
|
||||
'ip' => $request->get('ip', ''),
|
||||
];
|
||||
|
||||
$query = Activity::query()
|
||||
->with([
|
||||
'loggable' => fn ($query) => $query->withTrashed(),
|
||||
'user',
|
||||
])
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder());
|
||||
|
||||
if ($filters['event']) {
|
||||
$query->where('type', '=', $filters['event']);
|
||||
}
|
||||
if ($filters['user']) {
|
||||
$query->where('user_id', '=', $filters['user']);
|
||||
}
|
||||
|
||||
if ($filters['date_from']) {
|
||||
$query->where('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
if ($filters['date_to']) {
|
||||
$query->where('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
if ($filters['ip']) {
|
||||
$query->where('ip', 'like', $filters['ip'] . '%');
|
||||
}
|
||||
|
||||
$activities = $query->paginate(100);
|
||||
$activities->appends($request->all());
|
||||
|
||||
$types = ActivityType::all();
|
||||
$this->setPageTitle(trans('settings.audit'));
|
||||
|
||||
return view('settings.audit', [
|
||||
'activities' => $activities,
|
||||
'filters' => $filters,
|
||||
'listOptions' => $listOptions,
|
||||
'activityTypes' => $types,
|
||||
'filterSortUrl' => new SortUrl('settings/audit', array_filter($request->except('page')))
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\CommentRepo;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* The comment data model has a 'local_id' property, which is a unique integer ID
|
||||
* scoped to the page which the comment is on. The 'parent_id' is used for replies
|
||||
* and refers to the 'local_id' of the parent comment on the same page, not the main
|
||||
* globally unique 'id'.
|
||||
*
|
||||
* If you want to get all comments for a page in a tree-like structure, as reflected in
|
||||
* the UI, then that is provided on pages-read API responses.
|
||||
*/
|
||||
class CommentApiController extends ApiController
|
||||
{
|
||||
protected array $rules = [
|
||||
'create' => [
|
||||
'page_id' => ['required', 'integer'],
|
||||
'reply_to' => ['nullable', 'integer'],
|
||||
'html' => ['required', 'string'],
|
||||
'content_ref' => ['string'],
|
||||
],
|
||||
'update' => [
|
||||
'html' => ['string'],
|
||||
'archived' => ['boolean'],
|
||||
]
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected CommentRepo $commentRepo,
|
||||
protected PageQueries $pageQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a listing of comments visible to the user.
|
||||
*/
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
$query = $this->commentRepo->getQueryForVisible();
|
||||
|
||||
return $this->apiListingResponse($query, [
|
||||
'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment on a page.
|
||||
* If commenting as a reply to an existing comment, the 'reply_to' parameter
|
||||
* should be provided, set to the 'local_id' of the comment being replied to.
|
||||
*/
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$this->checkPermission(Permission::CommentCreateAll);
|
||||
|
||||
$input = $this->validate($request, $this->rules()['create']);
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']);
|
||||
|
||||
$comment = $this->commentRepo->create(
|
||||
$page,
|
||||
$input['html'],
|
||||
$input['reply_to'] ?? null,
|
||||
$input['content_ref'] ?? '',
|
||||
);
|
||||
|
||||
return response()->json($comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the details of a single comment, along with its direct replies.
|
||||
*/
|
||||
public function read(string $id): JsonResponse
|
||||
{
|
||||
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||
$comment->load('createdBy', 'updatedBy');
|
||||
|
||||
$replies = $this->commentRepo->getQueryForVisible()
|
||||
->where('parent_id', '=', $comment->local_id)
|
||||
->where('commentable_id', '=', $comment->commentable_id)
|
||||
->where('commentable_type', '=', $comment->commentable_type)
|
||||
->get();
|
||||
|
||||
/** @var Comment[] $toProcess */
|
||||
$toProcess = [$comment, ...$replies];
|
||||
foreach ($toProcess as $commentToProcess) {
|
||||
$commentToProcess->setAttribute('html', $commentToProcess->safeHtml());
|
||||
$commentToProcess->makeVisible('html');
|
||||
}
|
||||
|
||||
$comment->setRelation('replies', $replies);
|
||||
|
||||
return response()->json($comment);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the content or archived status of an existing comment.
|
||||
*
|
||||
* Only provide a new archived status if needing to actively change the archive state.
|
||||
* Only top-level comments (non-replies) can be archived or unarchived.
|
||||
*/
|
||||
public function update(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
|
||||
|
||||
$input = $this->validate($request, $this->rules()['update']);
|
||||
$hasHtml = isset($input['html']);
|
||||
|
||||
if (isset($input['archived'])) {
|
||||
if ($input['archived']) {
|
||||
$this->commentRepo->archive($comment, !$hasHtml);
|
||||
} else {
|
||||
$this->commentRepo->unarchive($comment, !$hasHtml);
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasHtml) {
|
||||
$comment = $this->commentRepo->update($comment, $input['html']);
|
||||
}
|
||||
|
||||
return response()->json($comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single comment from the system.
|
||||
*/
|
||||
public function delete(string $id): Response
|
||||
{
|
||||
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
|
||||
|
||||
$this->commentRepo->delete($comment);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\CommentRepo;
|
||||
use BookStack\Activity\Tools\CommentTree;
|
||||
use BookStack\Activity\Tools\CommentTreeNode;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected CommentRepo $commentRepo,
|
||||
protected PageQueries $pageQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new comment for a Page.
|
||||
*
|
||||
* @throws ValidationException|\Exception
|
||||
*/
|
||||
public function savePageComment(Request $request, int $pageId)
|
||||
{
|
||||
$input = $this->validate($request, [
|
||||
'html' => ['required', 'string'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
'content_ref' => ['string'],
|
||||
]);
|
||||
|
||||
$page = $this->pageQueries->findVisibleById($pageId);
|
||||
if ($page === null) {
|
||||
return response('Not found', 404);
|
||||
}
|
||||
|
||||
// Create a new comment.
|
||||
$this->checkPermission(Permission::CommentCreateAll);
|
||||
$contentRef = $input['content_ref'] ?? '';
|
||||
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef);
|
||||
|
||||
return view('comments.comment-branch', [
|
||||
'readOnly' => false,
|
||||
'branch' => new CommentTreeNode($comment, 0, []),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing comment.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(Request $request, int $commentId)
|
||||
{
|
||||
$input = $this->validate($request, [
|
||||
'html' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$comment = $this->commentRepo->getById($commentId);
|
||||
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
|
||||
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
|
||||
|
||||
$comment = $this->commentRepo->update($comment, $input['html']);
|
||||
|
||||
return view('comments.comment', [
|
||||
'comment' => $comment,
|
||||
'readOnly' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a comment as archived.
|
||||
*/
|
||||
public function archive(int $id)
|
||||
{
|
||||
$comment = $this->commentRepo->getById($id);
|
||||
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
|
||||
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
$this->commentRepo->archive($comment);
|
||||
|
||||
$tree = new CommentTree($comment->entity);
|
||||
return view('comments.comment-branch', [
|
||||
'readOnly' => false,
|
||||
'branch' => $tree->getCommentNodeForId($id),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmark a comment as archived.
|
||||
*/
|
||||
public function unarchive(int $id)
|
||||
{
|
||||
$comment = $this->commentRepo->getById($id);
|
||||
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
|
||||
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
$this->commentRepo->unarchive($comment);
|
||||
|
||||
$tree = new CommentTree($comment->entity);
|
||||
return view('comments.comment-branch', [
|
||||
'readOnly' => false,
|
||||
'branch' => $tree->getCommentNodeForId($id),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment from the system.
|
||||
*/
|
||||
public function destroy(int $id)
|
||||
{
|
||||
$comment = $this->commentRepo->getById($id);
|
||||
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
|
||||
|
||||
$this->commentRepo->delete($comment);
|
||||
|
||||
return response()->json(['message' => trans('entities.comment_deleted')]);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\QueryTopFavourites;
|
||||
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class FavouriteController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected MixedEntityRequestHelper $entityHelper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a listing of all favourite items for the current user.
|
||||
*/
|
||||
public function index(Request $request, QueryTopFavourites $topFavourites)
|
||||
{
|
||||
$viewCount = 20;
|
||||
$page = intval($request->get('page', 1));
|
||||
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
|
||||
|
||||
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
|
||||
|
||||
$this->setPageTitle(trans('entities.my_favourites'));
|
||||
|
||||
return view('common.detailed-listing-with-more', [
|
||||
'title' => trans('entities.my_favourites'),
|
||||
'entities' => $favourites->slice(0, $viewCount),
|
||||
'hasMoreLink' => $hasMoreLink,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new item as a favourite.
|
||||
*/
|
||||
public function add(Request $request)
|
||||
{
|
||||
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
|
||||
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
|
||||
$entity->favourites()->firstOrCreate([
|
||||
'user_id' => user()->id,
|
||||
]);
|
||||
|
||||
$this->showSuccessNotification(trans('activities.favourite_add_notification', [
|
||||
'name' => $entity->name,
|
||||
]));
|
||||
|
||||
return redirect($entity->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item as a favourite.
|
||||
*/
|
||||
public function remove(Request $request)
|
||||
{
|
||||
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
|
||||
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
|
||||
$entity->favourites()->where([
|
||||
'user_id' => user()->id,
|
||||
])->delete();
|
||||
|
||||
$this->showSuccessNotification(trans('activities.favourite_remove_notification', [
|
||||
'name' => $entity->name,
|
||||
]));
|
||||
|
||||
return redirect($entity->getUrl());
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\TagRepo;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TagController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected TagRepo $tagRepo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a listing of existing tags in the system.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'tags')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'usages' => trans('entities.tags_usages'),
|
||||
]);
|
||||
|
||||
$nameFilter = $request->get('name', '');
|
||||
$tags = $this->tagRepo
|
||||
->queryWithTotals($listOptions, $nameFilter)
|
||||
->paginate(50)
|
||||
->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [
|
||||
'name' => $nameFilter,
|
||||
])));
|
||||
|
||||
$this->setPageTitle(trans('entities.tags'));
|
||||
|
||||
return view('tags.index', [
|
||||
'tags' => $tags,
|
||||
'nameFilter' => $nameFilter,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag name suggestions from a given search term.
|
||||
*/
|
||||
public function getNameSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search', '');
|
||||
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
||||
|
||||
return response()->json($suggestions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag value suggestions from a given search term.
|
||||
*/
|
||||
public function getValueSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search', '');
|
||||
$tagName = $request->get('name', '');
|
||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
||||
|
||||
return response()->json($suggestions);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WatchController extends Controller
|
||||
{
|
||||
public function update(Request $request, MixedEntityRequestHelper $entityHelper)
|
||||
{
|
||||
$this->checkPermission(Permission::ReceiveNotifications);
|
||||
$this->preventGuestAccess();
|
||||
|
||||
$requestData = $this->validate($request, array_merge([
|
||||
'level' => ['required', 'string'],
|
||||
], $entityHelper->validationRules()));
|
||||
|
||||
$watchable = $entityHelper->getVisibleEntityFromRequestData($requestData);
|
||||
$watchOptions = new UserEntityWatchOptions(user(), $watchable);
|
||||
$watchOptions->updateLevelByName($requestData['level']);
|
||||
|
||||
$this->showSuccessNotification(trans('activities.watch_update_level_notification'));
|
||||
|
||||
return redirect($watchable->getUrl());
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\Webhook;
|
||||
use BookStack\Activity\Queries\WebhooksAllPaginatedAndSorted;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WebhookController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware([
|
||||
Permission::SettingsManage->middleware()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show all webhooks configured in the system.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'webhooks')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'endpoint' => trans('settings.webhooks_endpoint'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
'active' => trans('common.status'),
|
||||
]);
|
||||
|
||||
$webhooks = (new WebhooksAllPaginatedAndSorted())->run(20, $listOptions);
|
||||
$webhooks->appends($listOptions->getPaginationAppends());
|
||||
|
||||
$this->setPageTitle(trans('settings.webhooks'));
|
||||
|
||||
return view('settings.webhooks.index', [
|
||||
'webhooks' => $webhooks,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view for creating a new webhook in the system.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$this->setPageTitle(trans('settings.webhooks_create'));
|
||||
|
||||
return view('settings.webhooks.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new webhook in the system.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'max:150'],
|
||||
'endpoint' => ['required', 'url', 'max:500'],
|
||||
'events' => ['required', 'array'],
|
||||
'active' => ['required'],
|
||||
'timeout' => ['required', 'integer', 'min:1', 'max:600'],
|
||||
]);
|
||||
|
||||
$webhook = new Webhook($validated);
|
||||
$webhook->active = $validated['active'] === 'true';
|
||||
$webhook->save();
|
||||
$webhook->updateTrackedEvents(array_values($validated['events']));
|
||||
|
||||
$this->logActivity(ActivityType::WEBHOOK_CREATE, $webhook);
|
||||
|
||||
return redirect('/settings/webhooks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to edit an existing webhook.
|
||||
*/
|
||||
public function edit(string $id)
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()
|
||||
->with('trackedEvents')
|
||||
->findOrFail($id);
|
||||
|
||||
$this->setPageTitle(trans('settings.webhooks_edit'));
|
||||
|
||||
return view('settings.webhooks.edit', ['webhook' => $webhook]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing webhook with the provided request data.
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'max:150'],
|
||||
'endpoint' => ['required', 'url', 'max:500'],
|
||||
'events' => ['required', 'array'],
|
||||
'active' => ['required'],
|
||||
'timeout' => ['required', 'integer', 'min:1', 'max:600'],
|
||||
]);
|
||||
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->findOrFail($id);
|
||||
|
||||
$webhook->active = $validated['active'] === 'true';
|
||||
$webhook->fill($validated)->save();
|
||||
$webhook->updateTrackedEvents($validated['events']);
|
||||
|
||||
$this->logActivity(ActivityType::WEBHOOK_UPDATE, $webhook);
|
||||
|
||||
return redirect('/settings/webhooks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to delete a webhook.
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->findOrFail($id);
|
||||
|
||||
$this->setPageTitle(trans('settings.webhooks_delete'));
|
||||
|
||||
return view('settings.webhooks.delete', ['webhook' => $webhook]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a webhook from the system.
|
||||
*/
|
||||
public function destroy(string $id)
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->findOrFail($id);
|
||||
|
||||
$webhook->trackedEvents()->delete();
|
||||
$webhook->delete();
|
||||
|
||||
$this->logActivity(ActivityType::WEBHOOK_DELETE, $webhook);
|
||||
|
||||
return redirect('/settings/webhooks');
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Models\Webhook;
|
||||
use BookStack\Activity\Tools\WebhookFormatter;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Users\Models\User;
|
||||
use BookStack\Util\SsrUrlValidator;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DispatchWebhookJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
protected Webhook $webhook;
|
||||
protected User $initiator;
|
||||
protected int $initiatedTime;
|
||||
protected array $webhookData;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Webhook $webhook, string $event, Loggable|string $detail)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
$this->initiator = user();
|
||||
$this->initiatedTime = time();
|
||||
|
||||
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $event, $this->webhook, $detail, $this->initiator, $this->initiatedTime);
|
||||
$this->webhookData = $themeResponse ?? WebhookFormatter::getDefault($event, $this->webhook, $detail, $this->initiator, $this->initiatedTime)->format();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle(HttpRequestService $http)
|
||||
{
|
||||
$lastError = null;
|
||||
|
||||
try {
|
||||
(new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint);
|
||||
|
||||
$client = $http->buildClient($this->webhook->timeout, [
|
||||
'connect_timeout' => 10,
|
||||
'allow_redirects' => ['strict' => true],
|
||||
]);
|
||||
|
||||
$response = $client->sendRequest($http->jsonRequest('POST', $this->webhook->endpoint, $this->webhookData));
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
if ($statusCode >= 400) {
|
||||
$lastError = "Response status from endpoint was {$statusCode}";
|
||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$statusCode}");
|
||||
}
|
||||
} catch (\Exception $error) {
|
||||
$lastError = $error->getMessage();
|
||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
|
||||
}
|
||||
|
||||
$this->webhook->last_called_at = now();
|
||||
if ($lastError) {
|
||||
$this->webhook->last_errored_at = now();
|
||||
$this->webhook->last_error = $lastError;
|
||||
}
|
||||
|
||||
$this->webhook->save();
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property string $type
|
||||
* @property User $user
|
||||
* @property Entity $loggable
|
||||
* @property string $detail
|
||||
* @property string $loggable_type
|
||||
* @property int $loggable_id
|
||||
* @property int $user_id
|
||||
* @property Carbon $created_at
|
||||
*/
|
||||
class Activity extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Get the loggable model related to this activity.
|
||||
* Currently only used for entities (previously entity_[id/type] columns).
|
||||
* Could be used for others but will need an audit of uses where assumed
|
||||
* to be entities.
|
||||
*/
|
||||
public function loggable(): MorphTo
|
||||
{
|
||||
return $this->morphTo('loggable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user this activity relates to.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'loggable_id')
|
||||
->whereColumn('activities.loggable_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns text from the language files, Looks up by using the activity key.
|
||||
*/
|
||||
public function getText(): string
|
||||
{
|
||||
return trans('activities.' . $this->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this activity is intended to be for an entity.
|
||||
*/
|
||||
public function isForEntity(): bool
|
||||
{
|
||||
return Str::startsWith($this->type, [
|
||||
'page_', 'chapter_', 'book_', 'bookshelf_',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if another Activity matches the general information of another.
|
||||
*/
|
||||
public function isSimilarTo(self $activityB): bool
|
||||
{
|
||||
return [$this->type, $this->loggable_type, $this->loggable_id] === [$activityB->type, $activityB->loggable_type, $activityB->loggable_id];
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use BookStack\Users\Models\OwnableInterface;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $html
|
||||
* @property int|null $parent_id - Relates to local_id, not id
|
||||
* @property int $local_id
|
||||
* @property string $commentable_type
|
||||
* @property int $commentable_id
|
||||
* @property string $content_ref
|
||||
* @property bool $archived
|
||||
*/
|
||||
class Comment extends Model implements Loggable, OwnableInterface
|
||||
{
|
||||
use HasFactory;
|
||||
use HasCreatorAndUpdater;
|
||||
|
||||
protected $fillable = ['parent_id'];
|
||||
protected $hidden = ['html'];
|
||||
|
||||
protected $casts = [
|
||||
'archived' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the entity that this comment belongs to.
|
||||
*/
|
||||
public function entity(): MorphTo
|
||||
{
|
||||
// We specifically define null here to avoid the different name (commentable)
|
||||
// being used by Laravel eager loading instead of the method name, which it was doing
|
||||
// in some scenarios like when deserialized when going through the queue system.
|
||||
// So we instead specify the type and id column names to use.
|
||||
// Related to:
|
||||
// https://github.com/laravel/framework/pull/24815
|
||||
// https://github.com/laravel/framework/issues/27342
|
||||
// https://github.com/laravel/framework/issues/47953
|
||||
// (and probably more)
|
||||
|
||||
// Ultimately, we could just align the method name to 'commentable' but that would be a potential
|
||||
// breaking change and not really worthwhile in a patch due to the risk of creating extra problems.
|
||||
return $this->morphTo(null, 'commentable_type', 'commentable_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent comment this is in reply to (if existing).
|
||||
* @return BelongsTo<Comment, $this>
|
||||
*/
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
|
||||
->where('commentable_type', '=', $this->commentable_type)
|
||||
->where('commentable_id', '=', $this->commentable_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a comment has been updated since creation.
|
||||
*/
|
||||
public function isUpdated(): bool
|
||||
{
|
||||
return $this->updated_at->timestamp > $this->created_at->timestamp;
|
||||
}
|
||||
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})";
|
||||
}
|
||||
|
||||
public function safeHtml(): string
|
||||
{
|
||||
return HtmlContentFilter::removeActiveContentFromHtmlString($this->html ?? '');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id')
|
||||
->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope the query to just the comments visible to the user based upon the
|
||||
* user visibility of what has been commented on.
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return app()->make(PermissionApplicator::class)
|
||||
->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type');
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
interface Favouritable
|
||||
{
|
||||
/**
|
||||
* Get the related favourite instances.
|
||||
*/
|
||||
public function favourites(): MorphMany;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user