diff --git a/.env.example b/.env.example index ddb32c0bc24..13114b8b076 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,24 @@ -# Environment -APP_ENV=production -APP_DEBUG=false +# This file, when named as ".env" in the root of your BookStack install +# folder, is used for the core configuration of the application. +# By default this file contains the most common required options but +# a full list of options can be found in the '.env.example.complete' file. + +# NOTE: If any of your values contain a space or a hash you will need to +# wrap the entire value in quotes. (eg. MAIL_FROM_NAME="BookStack Mailer") + +# Application key +# Used for encryption where needed. +# Run `php artisan key:generate` to generate a valid key. APP_KEY=SomeRandomString -# 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 +# 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 # Database details DB_HOST=localhost @@ -13,58 +26,28 @@ DB_DATABASE=database_database DB_USERNAME=database_username DB_PASSWORD=database_user_password -# 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 - -# 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 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/ 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 -# 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 -OKTA_BASE_URL=false -OKTA_KEY=false -OKTA_SECRET=false - -# External services such as Gravatar -DISABLE_EXTERNAL_SERVICES=false +# Mail system to use +# Can be 'smtp' or 'sendmail' +MAIL_DRIVER=smtp -# LDAP Settings -LDAP_SERVER=false -LDAP_BASE_DN=false -LDAP_DN=false -LDAP_PASS=false -LDAP_USER_FILTER=false -LDAP_VERSION=false +# Mail sender details +MAIL_FROM_NAME="BookStack" +MAIL_FROM=bookstack@example.com -# Mail settings -MAIL_DRIVER=smtp +# 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=1025 +MAIL_PORT=587 MAIL_USERNAME=null MAIL_PASSWORD=null -MAIL_ENCRYPTION=null \ No newline at end of file +MAIL_ENCRYPTION=null diff --git a/.env.example.complete b/.env.example.complete new file mode 100644 index 00000000000..6c773f601f1 --- /dev/null +++ b/.env.example.complete @@ -0,0 +1,441 @@ +# 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/ 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 JavaScript, and other potentiall dangerous content in page content. +# This also removes CSP-level JavaScript control. +# Note, if set to 'true' the page editor may still escape scripts. +# DEPRECATED: Use 'APP_CONTENT_FILTERING' instead as detailed below. Activiting this option +# effectively sets APP_CONTENT_FILTERING='' (No filtering) +ALLOW_CONTENT_SCRIPTS=false + +# Control the behaviour of content filtering, primarily used for page content. +# This setting is a string of characters which represent different available filters: +# - j - Filter out JavaScript and unknown binary data based content +# - h - Filter out unexpected, and potentially dangerous, HTML elements +# - f - Filter out unexpected form elements +# - a - Run content through a more complex allowlist filter +# This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used. +# Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures. +# Note: The default value will always be the most-strict, so it's advised to leave this unset in your own configuration +# to ensure you are always using the full range of filters. +APP_CONTENT_FILTERING="jfha" + +# 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 sources/hostnames that can be loaded as CSS styles within BookStack. +# Space separated if multiple. BookStack host domain is auto-inferred. +# Defaults to a permissive set if not provided. +# Example: ALLOWED_STYLE_SOURCES="https://fonts.googleapis.com" +ALLOWED_STYLE_SOURCES=null + +# A list of sources/hostnames that can be loaded as image content within BookStack. +# Space separated if multiple. BookStack host domain is auto-inferred, in addition to +# data and blob images, due to their use for various functionality. +# Defaults to a permissive set if not provided. +# Example: ALLOWED_IMAGE_SOURCES="https://images.example.com" +ALLOWED_IMAGE_SOURCES=null + +# 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 diff --git a/.forgejo/CODE_OF_CONDUCT.md b/.forgejo/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..7a02656725d --- /dev/null +++ b/.forgejo/CODE_OF_CONDUCT.md @@ -0,0 +1,2 @@ +Please find our community rules on our website here: +https://www.bookstackapp.com/about/community-rules/ \ No newline at end of file diff --git a/.forgejo/FUNDING.yml b/.forgejo/FUNDING.yml new file mode 100644 index 00000000000..5c50c3f691c --- /dev/null +++ b/.forgejo/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: [ssddanbrown] +ko_fi: ssddanbrown diff --git a/.forgejo/ISSUE_TEMPLATE/api_request.yml b/.forgejo/ISSUE_TEMPLATE/api_request.yml new file mode 100644 index 00000000000..c68d262580b --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/api_request.yml @@ -0,0 +1,25 @@ +name: New API Endpoint or API Ability +description: Request a new endpoint or API feature be added +labels: ["Type/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 diff --git a/.forgejo/ISSUE_TEMPLATE/bug_report.yml b/.forgejo/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000000..9e4173cc798 --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,61 @@ +name: Bug Report +description: Create a report to help us fix bugs & issues in existing supported functionality +labels: ["Type/Bug Report"] +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 diff --git a/.forgejo/ISSUE_TEMPLATE/config.yml b/.forgejo/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..a72fb1ef4cb --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,13 @@ +blank_issues_enabled: false +contact_links: + - name: Community Forum Support + url: https://community.bookstackapp.com + about: Get support by talking with the BookStack team & community. + + - 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. \ No newline at end of file diff --git a/.forgejo/ISSUE_TEMPLATE/feature_request.yml b/.forgejo/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000000..0d799d0a78a --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,68 @@ +name: Feature Request +description: Request a new feature or idea to be added to BookStack +labels: ["Type/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://codeberg.org/bookstack/bookstack/issues) 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 + - type: checkboxes + id: ai-thoughts + attributes: + label: Have you used generative AI/LLMs to create any thoughts in this request? + description: | + We ask that no machine generated thoughts or ideas are provided, to avoid us spending time considering the ideas + of a machine instead of a human. Further guidance on this can be found [in the BookStack community rules](https://www.bookstackapp.com/about/community-rules/#use-of-llmsai). + options: + - label: This request only contains the thoughts & ideas of a human + required: true diff --git a/.forgejo/ISSUE_TEMPLATE/language_request.yml b/.forgejo/ISSUE_TEMPLATE/language_request.yml new file mode 100644 index 00000000000..b86fb08e84f --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/language_request.yml @@ -0,0 +1,31 @@ +name: Language Request +description: Request a new language to be added to Crowdin for you to translate +labels: ["Focus: 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.__* diff --git a/.forgejo/ISSUE_TEMPLATE/support_request.yml b/.forgejo/ISSUE_TEMPLATE/support_request.yml new file mode 100644 index 00000000000..fde4aad7141 --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/support_request.yml @@ -0,0 +1,55 @@ +name: Support Request +description: Request support for a specific problem you have not been able to solve yourself +labels: ["Type/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 Existing Issues + description: | + I have searched for the issue and potential resolutions within the [project's issue list](https://codeberg.org/bookstack/bookstack/issues) + options: + - label: I have searched 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 diff --git a/.forgejo/ISSUE_TEMPLATE/z_blank_request.yml b/.forgejo/ISSUE_TEMPLATE/z_blank_request.yml new file mode 100644 index 00000000000..5cb5ed2a4d5 --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/z_blank_request.yml @@ -0,0 +1,9 @@ +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 \ No newline at end of file diff --git a/.forgejo/SECURITY.md b/.forgejo/SECURITY.md new file mode 100644 index 00000000000..5c044c94629 --- /dev/null +++ b/.forgejo/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Supported Versions + +Only the [latest version](https://codeberg.org/bookstack/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, an issue only in the development branch) +feel free to raise it via a standard Codeberg bug report issue. + +If the issue could have a security impact to BookStack instances, +please directly contact the lead maintainer via email Dan Brown using the [details found here](https://www.bookstackapp.com/links/contact/). + +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! \ No newline at end of file diff --git a/.forgejo/pull_request_template.md b/.forgejo/pull_request_template.md new file mode 100644 index 00000000000..70f1058748c --- /dev/null +++ b/.forgejo/pull_request_template.md @@ -0,0 +1,11 @@ +## Details + + + + +## Checklist + + + +- [ ] I have read the [BookStack community rules](https://www.bookstackapp.com/about/community-rules/). +- [ ] This PR does not feature significant use of LLM/AI generation as per the community rules above. diff --git a/.forgejo/workflows/analyse-php.yml b/.forgejo/workflows/analyse-php.yml new file mode 100644 index 00000000000..3a07d9bd5c0 --- /dev/null +++ b/.forgejo/workflows/analyse-php.yml @@ -0,0 +1,45 @@ +name: analyse-php + +on: + workflow_dispatch: + push: + paths: + - '**.php' + pull_request: + paths: + - '**.php' + +jobs: + build: + if: ${{ github.ref != 'refs/heads/l10n_development' }} + runs-on: docker + container: + image: docker.io/library/node:24-trixie + steps: + - uses: https://code.forgejo.org/actions/checkout@v6 + + - name: Setup PHP + uses: https://github.com/shivammathur/setup-php@v2 + with: + php-version: 8.5 + extensions: gd, mbstring, json, curl, xml, dom, 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: https://code.forgejo.org/actions/cache@v5 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-8.5 + restore-keys: ${{ runner.os }}-composer- + + - name: Install composer dependencies + run: composer install --prefer-dist --no-interaction --ansi + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_TOKEN }}"}}' + + - name: Run static analysis check + run: composer check-static diff --git a/.forgejo/workflows/lint-js.yml b/.forgejo/workflows/lint-js.yml new file mode 100644 index 00000000000..5cacec67aab --- /dev/null +++ b/.forgejo/workflows/lint-js.yml @@ -0,0 +1,27 @@ +name: lint-js + +on: + workflow_dispatch: + push: + paths: + - '**.js' + - '**.json' + pull_request: + paths: + - '**.js' + - '**.json' + +jobs: + build: + if: ${{ github.ref != 'refs/heads/l10n_development' }} + runs-on: docker + container: + image: docker.io/library/node:24-trixie + steps: + - uses: https://code.forgejo.org/actions/checkout@v6 + + - name: Install NPM deps + run: npm ci + + - name: Run formatting check + run: npm run lint diff --git a/.forgejo/workflows/lint-php.yml b/.forgejo/workflows/lint-php.yml new file mode 100644 index 00000000000..b409c62e254 --- /dev/null +++ b/.forgejo/workflows/lint-php.yml @@ -0,0 +1,28 @@ +name: lint-php + +on: + workflow_dispatch: + push: + paths: + - '**.php' + pull_request: + paths: + - '**.php' + +jobs: + build: + if: ${{ github.ref != 'refs/heads/l10n_development' }} + runs-on: docker + container: + image: docker.io/library/node:24-trixie + steps: + - uses: https://code.forgejo.org/actions/checkout@v6 + + - name: Setup PHP + uses: https://github.com/shivammathur/setup-php@v2 + with: + php-version: 8.5 + tools: phpcs + + - name: Run formatting check + run: composer lint diff --git a/.forgejo/workflows/sync-translations.yml b/.forgejo/workflows/sync-translations.yml new file mode 100644 index 00000000000..9501c15e133 --- /dev/null +++ b/.forgejo/workflows/sync-translations.yml @@ -0,0 +1,34 @@ +name: Crowdin Action + +on: + push: + branches: [ development ] + paths: + - 'lang/**.php' + schedule: + - cron: '30 4 * * *' + workflow_dispatch: + +jobs: + synchronize-with-crowdin: + runs-on: docker + container: + image: docker.io/library/node:24-trixie + + steps: + - name: Checkout + uses: https://code.forgejo.org/actions/checkout@v6 + + - name: crowdin action + uses: https://github.com/crowdin/github-action@v2 + with: + crowdin_branch_name: development + upload_sources: true + upload_translations: false + download_translations: true + localization_branch_name: l10n_development + create_pull_request: false + github_base_url: codeberg.org + env: + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} \ No newline at end of file diff --git a/.forgejo/workflows/test-js.yml b/.forgejo/workflows/test-js.yml new file mode 100644 index 00000000000..d3e8467fe09 --- /dev/null +++ b/.forgejo/workflows/test-js.yml @@ -0,0 +1,32 @@ +name: test-js + +on: + workflow_dispatch: + push: + paths: + - '**.js' + - '**.ts' + - '**.json' + pull_request: + paths: + - '**.js' + - '**.ts' + - '**.json' + +jobs: + build: + if: ${{ github.ref != 'refs/heads/l10n_development' }} + runs-on: docker + container: + image: docker.io/library/node:24-trixie + steps: + - uses: https://code.forgejo.org/actions/checkout@v6 + + - 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:ci \ No newline at end of file diff --git a/.forgejo/workflows/test-migrations.yml b/.forgejo/workflows/test-migrations.yml new file mode 100644 index 00000000000..5848f54d901 --- /dev/null +++ b/.forgejo/workflows/test-migrations.yml @@ -0,0 +1,81 @@ +name: test-migrations + +on: + workflow_dispatch: + push: + paths: + - '**.php' + - 'composer.*' + pull_request: + paths: + - '**.php' + - 'composer.*' + +jobs: + build: + if: ${{ github.ref != 'refs/heads/l10n_development' }} + runs-on: docker + container: + image: docker.io/library/node:24-trixie + strategy: + matrix: + php: ['8.2', '8.3', '8.4', '8.5'] + services: + mysql: + image: docker.io/library/mariadb:12.2.2-noble + options: --tmpfs /var/lib/mysql:rw + cmd: + - --innodb-flush-log-at-trx-commit=0 + - --innodb-flush-method=O_DIRECT + - --innodb-doublewrite=0 + - --innodb-buffer-pool-size=256M + - --skip-log-bin + - --sync-binlog=0 + env: + MARIADB_USER: bookstack-test + MARIADB_PASSWORD: bookstack-test + MARIADB_DATABASE: bookstack-test + MARIADB_ROOT_PASSWORD: password + steps: + - uses: https://code.forgejo.org/actions/checkout@v6 + + - name: Setup PHP + uses: https://github.com/shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: gd, mbstring, json, curl, xml, dom, 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: https://code.forgejo.org/actions/cache@v5 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ matrix.php }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install composer dependencies + run: composer install --prefer-dist --no-interaction --ansi + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_TOKEN }}"}}' + + - name: Start migration test + env: + TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test' + run: | + php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing + + - name: Start migration:rollback test + env: + TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test' + run: | + php${{ matrix.php }} artisan migrate:rollback --force -n --database=mysql_testing + + - name: Start migration rerun test + env: + TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test' + run: | + php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing diff --git a/.forgejo/workflows/test-php.yml b/.forgejo/workflows/test-php.yml new file mode 100644 index 00000000000..99e1af518f7 --- /dev/null +++ b/.forgejo/workflows/test-php.yml @@ -0,0 +1,88 @@ +name: test-php + +on: + workflow_dispatch: + push: + paths: + - '**.php' + - 'composer.*' + pull_request: + paths: + - '**.php' + - 'composer.*' + +jobs: + build: + if: ${{ github.ref != 'refs/heads/l10n_development' }} + runs-on: docker + container: + image: docker.io/setupphp/node:noble + strategy: + matrix: + php: ['8.2', '8.3', '8.4', '8.5'] + env: + phpextensions: gd, mbstring, json, curl, xml, dom, mysql, ldap, gmp + phpextensioncachekey: cache-v1 + steps: + - uses: https://code.forgejo.org/actions/checkout@v6 + + - name: Setup cache environment + id: extcache + uses: https://github.com/shivammathur/cache-extensions@v1 + with: + php-version: ${{ matrix.php }} + extensions: ${{ env.phpextensions }} + key: ${{ env.phpextensioncachekey }} + + - name: Cache extensions + uses: https://code.forgejo.org/actions/cache@v5 + with: + path: ${{ steps.extcache.outputs.dir }} + key: ${{ steps.extcache.outputs.key }} + restore-keys: ${{ steps.extcache.outputs.key }} + + - name: Setup PHP + uses: https://github.com/shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: ${{ env.phpextensions }} + + - name: Get Composer Cache Directory + id: composer-cache + run: | + echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache composer packages + uses: https://code.forgejo.org/actions/cache@v5 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ matrix.php }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install composer dependencies + run: composer install --prefer-dist --no-interaction --ansi + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_TOKEN }}"}}' + + - 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: Migrate and seed the database + env: + TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@localhost/bookstack-test' + 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 + env: + TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@localhost/bookstack-test' + run: php${{ matrix.php }} ./vendor/bin/phpunit diff --git a/.forgejo/workflows/update-snyk.yml b/.forgejo/workflows/update-snyk.yml new file mode 100644 index 00000000000..89ce68ae3a4 --- /dev/null +++ b/.forgejo/workflows/update-snyk.yml @@ -0,0 +1,33 @@ +name: update-snyk + +on: + workflow_dispatch: + push: + paths: + - 'composer*' + - 'package*' + branches: + - 'development' + - 'release' + +jobs: + update: + runs-on: docker + container: + image: docker.io/library/node:24-trixie + steps: + - uses: https://code.forgejo.org/actions/checkout@v6 + + - name: Update Snyk for monitoring - Composer + uses: https://github.com/snyk/actions/node@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: snyk monitor --file=composer.lock --project-name=bookstack-${{forgejo.ref_name}}-composer + + - name: Update Snyk for monitoring - NPM + uses: https://github.com/snyk/actions/node@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: snyk monitor --file=package-lock.json --project-name=bookstack-${{forgejo.ref_name}}-npm \ No newline at end of file diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..7a02656725d --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,2 @@ +Please find our community rules on our website here: +https://www.bookstackapp.com/about/community-rules/ \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000000..f3f51c7943c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: [ssddanbrown] +ko_fi: ssddanbrown \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 544bd4e879b..00000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,21 +0,0 @@ -### For Feature Requests - -Desired Feature: - -### For Bug Reports - -* BookStack Version *(Found in settings, Please don't put 'latest')*: -* PHP Version: -* MySQL Version: - -##### Expected Behavior - - - -##### Current Behavior - - - -##### Steps to Reproduce - - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..0cd657d7a50 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,13 @@ +blank_issues_enabled: false +contact_links: + - name: Open Issues Here Instead + url: https://codeberg.org/bookstack/bookstack/issues + about: This project has migrated to Codeberg, please open issues there instead. + + - 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. \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000000..c185f3280b6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,10 @@ +**Warning:** + +This project has migrated to Codeberg: +https://codeberg.org/bookstack/bookstack + +Please open pull requests here instead. + +ANY PULL REQUESTS OPENED HERE WILL BE CLOSED WITHOUT COMMENT OR MERGE. + +--- \ No newline at end of file diff --git a/.github/translators.txt b/.github/translators.txt new file mode 100644 index 00000000000..a6e01d0ed8f --- /dev/null +++ b/.github/translators.txt @@ -0,0 +1,554 @@ +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 +Calle Calmar (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 +Charllys Fernandes (CharllysFernandes) :: Portuguese, Brazilian +Ilgiz Zigangirov (inov8) :: Russian +Max Israelsson (Blezie) :: Swedish +Skiddybison5924 (chris-devel0per) :: German Informal; German +Veyilla Nightwhisper (Veyilla) :: German +João Barbosa (hypeedd) :: Portuguese +Abcdefg Hijklmn (collatek) :: Korean +Suthep Yonphimai (tomztt) :: Thai +MrClock (MrClock8163) :: Hungarian +Elena0875 :: Russian +FelixFrizzy :: German +Pedro de Mattia (pdmtt) :: Portuguese, Brazilian +lonestan :: Russian +Paul Kernstock (kernstock) :: German +brtbr :: German; German Informal +Ricardo Covelo (covelo12) :: Portuguese +Bojan Maksimovic (PolarniMeda) :: Serbian (Cyrillic) +Dian Prawira (wiradian84) :: Indonesian +Tim (timakai) :: 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 +dadda123 :: Swedish +Julien Muggli (JulienMuggli) :: French +nomoreshow :: Turkish diff --git a/.gitignore b/.gitignore index be86e5a0392..06a8723c5c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ /vendor /node_modules +/.vscode +/composer +/composer.phar +/coverage Homestead.yaml .env .idea @@ -11,6 +15,7 @@ yarn-error.log /public/js /public/bower /public/build/ +/public/favicon.ico /storage/images _ide_helper.php /storage/debugbar @@ -20,4 +25,12 @@ yarn.lock nbproject .buildpath .project +.nvmrc .settings/ +webpack-stats.json +.phpunit.result.cache +.DS_Store +phpstan.neon +esbuild-meta.json +.phpactor.json +/*.zip diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 29727f488a9..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -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 diff --git a/LICENSE b/LICENSE index 281814bb8ce..d7961a61319 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016 Dan Brown +Copyright (c) 2015-2026, Dan Brown and the BookStack project 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 diff --git a/app/Access/Controllers/ConfirmEmailController.php b/app/Access/Controllers/ConfirmEmailController.php new file mode 100644 index 00000000000..d71b8f45068 --- /dev/null +++ b/app/Access/Controllers/ConfirmEmailController.php @@ -0,0 +1,121 @@ +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'); + } +} diff --git a/app/Access/Controllers/ForgotPasswordController.php b/app/Access/Controllers/ForgotPasswordController.php new file mode 100644 index 00000000000..e8127e6173a --- /dev/null +++ b/app/Access/Controllers/ForgotPasswordController.php @@ -0,0 +1,65 @@ +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->input('email')); + } + + if (in_array($response, [Password::RESET_LINK_SENT, Password::INVALID_USER, Password::RESET_THROTTLED])) { + $message = trans('auth.reset_password_sent', ['email' => $request->input('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)] + ); + } +} diff --git a/app/Access/Controllers/HandlesPartialLogins.php b/app/Access/Controllers/HandlesPartialLogins.php new file mode 100644 index 00000000000..8afad2776d1 --- /dev/null +++ b/app/Access/Controllers/HandlesPartialLogins.php @@ -0,0 +1,31 @@ +make(LoginService::class); + $user = auth()->user() ?? $loginService->getLastLoginAttemptUser(); + + if (!$user) { + throw new NotFoundException(trans('errors.login_user_not_found')); + } + + return $user; + } + + protected function clearLastAttemptedUser(): void + { + $loginService = app()->make(LoginService::class); + $loginService->clearLastLoginAttempted(); + } +} diff --git a/app/Access/Controllers/LoginController.php b/app/Access/Controllers/LoginController.php new file mode 100644 index 00000000000..fece3d88098 --- /dev/null +++ b/app/Access/Controllers/LoginController.php @@ -0,0 +1,209 @@ +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->input('prevent_auto_init') === 'true'; + + if ($request->has('email')) { + session()->flashInput([ + 'email' => $request->input('email'), + 'password' => (config('app.env') === 'demo') ? $request->input('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->input($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(''); + $comparison = new UrlComparison($previous, url('/')); + $isPreviousFromInstance = $comparison->originsMatch() && $comparison->pathsOverlap(); + 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); + } +} diff --git a/app/Access/Controllers/MfaBackupCodesController.php b/app/Access/Controllers/MfaBackupCodesController.php new file mode 100644 index 00000000000..b81e790976f --- /dev/null +++ b/app/Access/Controllers/MfaBackupCodesController.php @@ -0,0 +1,112 @@ +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(); + $this->limiter->incrementAttempts($user, $request); + if ($this->limiter->hasHitLimit($user, $request)) { + $this->clearLastAttemptedUser(); + $this->limiter->throwException(); + } + + $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->input('code'), $codes); + MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, $updatedCodes); + + $mfaSession->markVerifiedForUser($user); + $loginService->reattemptLoginFor($user); + $this->limiter->decrementAttempts($user, $request); + + if ($codeService->countCodesInSet($updatedCodes) < 5) { + $this->showWarningNotification(trans('auth.mfa_backup_codes_usage_limit_warning')); + } + + return redirect()->intended(); + } +} diff --git a/app/Access/Controllers/MfaController.php b/app/Access/Controllers/MfaController.php new file mode 100644 index 00000000000..181cfc0b84b --- /dev/null +++ b/app/Access/Controllers/MfaController.php @@ -0,0 +1,73 @@ +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->input('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, + ]); + } +} diff --git a/app/Access/Controllers/MfaTotpController.php b/app/Access/Controllers/MfaTotpController.php new file mode 100644 index 00000000000..b8a33322857 --- /dev/null +++ b/app/Access/Controllers/MfaTotpController.php @@ -0,0 +1,113 @@ +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(); + $this->limiter->incrementAttempts($user, $request); + if ($this->limiter->hasHitLimit($user, $request)) { + $this->clearLastAttemptedUser(); + $this->limiter->throwException(); + } + + $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); + $this->limiter->decrementAttempts($user, $request); + + return redirect()->intended(); + } +} diff --git a/app/Access/Controllers/OidcController.php b/app/Access/Controllers/OidcController.php new file mode 100644 index 00000000000..654ed692880 --- /dev/null +++ b/app/Access/Controllers/OidcController.php @@ -0,0 +1,75 @@ +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()); + } +} diff --git a/app/Access/Controllers/RegisterController.php b/app/Access/Controllers/RegisterController.php new file mode 100644 index 00000000000..f0261fba80d --- /dev/null +++ b/app/Access/Controllers/RegisterController.php @@ -0,0 +1,82 @@ +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(); + $userData = $this->validator($request->all())->validate(); + + 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'], + ]); + } +} diff --git a/app/Access/Controllers/ResetPasswordController.php b/app/Access/Controllers/ResetPasswordController.php new file mode 100644 index 00000000000..e81c98b288c --- /dev/null +++ b/app/Access/Controllers/ResetPasswordController.php @@ -0,0 +1,95 @@ +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->input('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)]); + } +} diff --git a/app/Access/Controllers/Saml2Controller.php b/app/Access/Controllers/Saml2Controller.php new file mode 100644 index 00000000000..39598b1435a --- /dev/null +++ b/app/Access/Controllers/Saml2Controller.php @@ -0,0 +1,128 @@ +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->input('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->input('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(); + } +} diff --git a/app/Access/Controllers/SocialController.php b/app/Access/Controllers/SocialController.php new file mode 100644 index 00000000000..5a090c7ca2a --- /dev/null +++ b/app/Access/Controllers/SocialController.php @@ -0,0 +1,137 @@ +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->input('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('/'); + } +} diff --git a/app/Access/Controllers/ThrottlesLogins.php b/app/Access/Controllers/ThrottlesLogins.php new file mode 100644 index 00000000000..25c3452f25e --- /dev/null +++ b/app/Access/Controllers/ThrottlesLogins.php @@ -0,0 +1,92 @@ +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; + } +} diff --git a/app/Access/Controllers/UserInviteController.php b/app/Access/Controllers/UserInviteController.php new file mode 100644 index 00000000000..091b68e5594 --- /dev/null +++ b/app/Access/Controllers/UserInviteController.php @@ -0,0 +1,101 @@ +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->input('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; + } +} diff --git a/app/Access/EmailConfirmationService.php b/app/Access/EmailConfirmationService.php new file mode 100644 index 00000000000..e950c5504b7 --- /dev/null +++ b/app/Access/EmailConfirmationService.php @@ -0,0 +1,42 @@ +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'); + } +} diff --git a/app/Access/ExternalBaseUserProvider.php b/app/Access/ExternalBaseUserProvider.php new file mode 100644 index 00000000000..ef001289d11 --- /dev/null +++ b/app/Access/ExternalBaseUserProvider.php @@ -0,0 +1,69 @@ +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 $this->userRepo->getByExternalAuthId($credentials['external_auth_id']); + } + + /** + * 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 + } +} diff --git a/app/Access/GroupSyncService.php b/app/Access/GroupSyncService.php new file mode 100644 index 00000000000..65e3e8fcd2c --- /dev/null +++ b/app/Access/GroupSyncService.php @@ -0,0 +1,86 @@ +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('/(? $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); + } + } +} diff --git a/app/Access/Guards/AsyncExternalBaseSessionGuard.php b/app/Access/Guards/AsyncExternalBaseSessionGuard.php new file mode 100644 index 00000000000..b66fbe95e09 --- /dev/null +++ b/app/Access/Guards/AsyncExternalBaseSessionGuard.php @@ -0,0 +1,31 @@ +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|null + { + 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; + } +} diff --git a/app/Access/Guards/LdapSessionGuard.php b/app/Access/Guards/LdapSessionGuard.php new file mode 100644 index 00000000000..9455d530dfe --- /dev/null +++ b/app/Access/Guards/LdapSessionGuard.php @@ -0,0 +1,128 @@ +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; + } +} diff --git a/app/Access/Ldap.php b/app/Access/Ldap.php new file mode 100644 index 00000000000..d14f688218c --- /dev/null +++ b/app/Access/Ldap.php @@ -0,0 +1,122 @@ +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); + } +} diff --git a/app/Access/LdapService.php b/app/Access/LdapService.php new file mode 100644 index 00000000000..0f456efc247 --- /dev/null +++ b/app/Access/LdapService.php @@ -0,0 +1,478 @@ +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}"); + } + } +} diff --git a/app/Access/LoginService.php b/app/Access/LoginService.php new file mode 100644 index 00000000000..46545f798b7 --- /dev/null +++ b/app/Access/LoginService.php @@ -0,0 +1,239 @@ +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']); + } + + /** + * 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. + */ + public 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. + $result = false; + } + } + + // Perform a dummy hash check to balance out the time of a login with an existing known user + // with that of a user not in the system (which we don't perform a hash check for in the above). + /** @var Authenticatable|null $lastAttempted */ + $lastAttempted = auth()->getLastAttempted(); + if (!$result && $lastAttempted === null) { + Hash::check($credentials['password'], '$2y$04$A.H9icXH4/lxLd9DHuaYqO/GVBd0OKetxyY0txmNfTAlPLVnTBx3y'); + } + + // Add some noise to request times on failed login attempts + if (!$result) { + $sleepMs = random_int(0, 250); + usleep($sleepMs * 1000); + } + + 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']); + } +} diff --git a/app/Access/Mfa/BackupCodeService.php b/app/Access/Mfa/BackupCodeService.php new file mode 100644 index 00000000000..7d68d4f92d7 --- /dev/null +++ b/app/Access/Mfa/BackupCodeService.php @@ -0,0 +1,62 @@ +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))); + } +} diff --git a/app/Access/Mfa/MfaSession.php b/app/Access/Mfa/MfaSession.php new file mode 100644 index 00000000000..b1285341257 --- /dev/null +++ b/app/Access/Mfa/MfaSession.php @@ -0,0 +1,59 @@ +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; + } +} diff --git a/app/Access/Mfa/MfaValue.php b/app/Access/Mfa/MfaValue.php new file mode 100644 index 00000000000..b0f08e82684 --- /dev/null +++ b/app/Access/Mfa/MfaValue.php @@ -0,0 +1,78 @@ +firstOrNew([ + 'user_id' => $user->id, + 'method' => $method, + ]); + $mfaVal->setValue($value); + $mfaVal->save(); + } + + /** + * Get the decrypted MFA value for the given user and method. + */ + public static function getValueForUser(User $user, string $method): ?string + { + $mfaVal = static::query() + ->where('user_id', '=', $user->id) + ->where('method', '=', $method) + ->first(); + + return $mfaVal?->getValue(); + } + + /** + * 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); + } +} diff --git a/app/Access/Mfa/MfaVerificationLimiter.php b/app/Access/Mfa/MfaVerificationLimiter.php new file mode 100644 index 00000000000..3ff0adad43d --- /dev/null +++ b/app/Access/Mfa/MfaVerificationLimiter.php @@ -0,0 +1,62 @@ + 60]), + '/login', + Response::HTTP_TOO_MANY_REQUESTS + ); + } + + public function incrementAttempts(User $user, Request $request): void + { + $this->rateLimiter->hit($this->getUserKey($user)); + $this->rateLimiter->hit($this->getRequestKey($request)); + } + + public function decrementAttempts(User $user, Request $request): void + { + $this->rateLimiter->decrement($this->getUserKey($user)); + $this->rateLimiter->decrement($this->getRequestKey($request)); + } + + public function hasHitLimit(User $user, Request $request): bool + { + return $this->rateLimiter->tooManyAttempts($this->getUserKey($user), $this->maxUserAttemptsPerMinute + 1) + || $this->rateLimiter->tooManyAttempts($this->getRequestKey($request), $this->maxIpAttemptsPerMinute + 1); + } + + protected function getUserKey(User $user): string + { + return "mfa-attempt::user::{$user->id}"; + } + + protected function getRequestKey(Request $request): string + { + return "mfa-attempt::request::{$request->ip()}"; + } +} diff --git a/app/Access/Mfa/TotpService.php b/app/Access/Mfa/TotpService.php new file mode 100644 index 00000000000..f13637a3f17 --- /dev/null +++ b/app/Access/Mfa/TotpService.php @@ -0,0 +1,72 @@ +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); + } +} diff --git a/app/Access/Mfa/TotpValidationRule.php b/app/Access/Mfa/TotpValidationRule.php new file mode 100644 index 00000000000..63b575f198b --- /dev/null +++ b/app/Access/Mfa/TotpValidationRule.php @@ -0,0 +1,27 @@ +totpService->verifyCode($value, $this->secret); + if (!$passes) { + $fail(trans('validation.totp')); + } + } +} diff --git a/app/Access/Notifications/ConfirmEmailNotification.php b/app/Access/Notifications/ConfirmEmailNotification.php new file mode 100644 index 00000000000..c67e1cf6283 --- /dev/null +++ b/app/Access/Notifications/ConfirmEmailNotification.php @@ -0,0 +1,26 @@ + 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)); + } +} diff --git a/app/Access/Notifications/ResetPasswordNotification.php b/app/Access/Notifications/ResetPasswordNotification.php new file mode 100644 index 00000000000..0a0f4ceb783 --- /dev/null +++ b/app/Access/Notifications/ResetPasswordNotification.php @@ -0,0 +1,24 @@ +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')); + } +} diff --git a/app/Access/Notifications/UserInviteNotification.php b/app/Access/Notifications/UserInviteNotification.php new file mode 100644 index 00000000000..dacce574f6e --- /dev/null +++ b/app/Access/Notifications/UserInviteNotification.php @@ -0,0 +1,27 @@ + 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)); + } +} diff --git a/app/Access/Oidc/OidcAccessToken.php b/app/Access/Oidc/OidcAccessToken.php new file mode 100644 index 00000000000..2ba56de6c0f --- /dev/null +++ b/app/Access/Oidc/OidcAccessToken.php @@ -0,0 +1,53 @@ +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']; + } +} diff --git a/app/Access/Oidc/OidcException.php b/app/Access/Oidc/OidcException.php new file mode 100644 index 00000000000..095d2f3ffc2 --- /dev/null +++ b/app/Access/Oidc/OidcException.php @@ -0,0 +1,9 @@ +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'); + } + } +} diff --git a/app/Access/Oidc/OidcInvalidKeyException.php b/app/Access/Oidc/OidcInvalidKeyException.php new file mode 100644 index 00000000000..14c310133ab --- /dev/null +++ b/app/Access/Oidc/OidcInvalidKeyException.php @@ -0,0 +1,7 @@ + 'RSA', 'alg' => 'RS256', 'n' => 'abc123...']. + * + * @throws OidcInvalidKeyException + */ + public function __construct(array|string $jwkOrKeyPath) + { + if (is_array($jwkOrKeyPath)) { + $this->loadFromJwkArray($jwkOrKeyPath); + } elseif (str_starts_with($jwkOrKeyPath, 'file://')) { + $this->loadFromPath($jwkOrKeyPath); + } else { + throw new OidcInvalidKeyException('Unexpected type of key value provided'); + } + } + + /** + * @throws OidcInvalidKeyException + */ + protected function loadFromPath(string $path): void + { + 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): void + { + // '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'); + } +} diff --git a/app/Access/Oidc/OidcJwtWithClaims.php b/app/Access/Oidc/OidcJwtWithClaims.php new file mode 100644 index 00000000000..9d7eeead1a9 --- /dev/null +++ b/app/Access/Oidc/OidcJwtWithClaims.php @@ -0,0 +1,174 @@ +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)) { + throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token"); + } + } + + if (empty($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'); + } + } +} diff --git a/app/Access/Oidc/OidcOAuthProvider.php b/app/Access/Oidc/OidcOAuthProvider.php new file mode 100644 index 00000000000..371bfcecb54 --- /dev/null +++ b/app/Access/Oidc/OidcOAuthProvider.php @@ -0,0 +1,127 @@ +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; + } +} diff --git a/app/Access/Oidc/OidcProviderSettings.php b/app/Access/Oidc/OidcProviderSettings.php new file mode 100644 index 00000000000..71c3b573421 --- /dev/null +++ b/app/Access/Oidc/OidcProviderSettings.php @@ -0,0 +1,200 @@ +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; + } +} diff --git a/app/Access/Oidc/OidcService.php b/app/Access/Oidc/OidcService.php new file mode 100644 index 00000000000..a84bd320513 --- /dev/null +++ b/app/Access/Oidc/OidcService.php @@ -0,0 +1,324 @@ +getProviderSettings(); + $provider = $this->getProvider($settings); + + $url = $provider->getAuthorizationUrl(); + session()->put('oidc_pkce_code', $provider->getPkceCode() ?? ''); + + $returnUrl = Theme::dispatch(ThemeEvents::OIDC_AUTH_PRE_REDIRECT, $url); + if (is_string($returnUrl)) { + $url = $returnUrl; + } + + 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); + } +} diff --git a/app/Access/Oidc/OidcUserDetails.php b/app/Access/Oidc/OidcUserDetails.php new file mode 100644 index 00000000000..b1736f97d31 --- /dev/null +++ b/app/Access/Oidc/OidcUserDetails.php @@ -0,0 +1,87 @@ +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; + } +} diff --git a/app/Access/Oidc/OidcUserinfoResponse.php b/app/Access/Oidc/OidcUserinfoResponse.php new file mode 100644 index 00000000000..33b8ec80665 --- /dev/null +++ b/app/Access/Oidc/OidcUserinfoResponse.php @@ -0,0 +1,69 @@ +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; + } +} diff --git a/app/Access/Oidc/ProvidesClaims.php b/app/Access/Oidc/ProvidesClaims.php new file mode 100644 index 00000000000..a3cf5165510 --- /dev/null +++ b/app/Access/Oidc/ProvidesClaims.php @@ -0,0 +1,17 @@ +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 = $this->userRepo->getByExternalAuthId($externalId); + + 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 the 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 a 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 the 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); + } + } +} diff --git a/app/Access/Saml2Service.php b/app/Access/Saml2Service.php new file mode 100644 index 00000000000..5572d210401 --- /dev/null +++ b/app/Access/Saml2Service.php @@ -0,0 +1,382 @@ +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|null, 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 (empty($userDetails['email'])) { + 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; + } +} diff --git a/app/Access/SocialAccount.php b/app/Access/SocialAccount.php new file mode 100644 index 00000000000..f52f74cc48c --- /dev/null +++ b/app/Access/SocialAccount.php @@ -0,0 +1,36 @@ + + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * {@inheritdoc} + */ + public function logDescriptor(): string + { + return "{$this->driver}; {$this->user->logDescriptor()}"; + } +} diff --git a/app/Access/SocialAuthService.php b/app/Access/SocialAuthService.php new file mode 100644 index 00000000000..bdcfb45c865 --- /dev/null +++ b/app/Access/SocialAuthService.php @@ -0,0 +1,185 @@ +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->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->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; + } +} diff --git a/app/Access/SocialDriverManager.php b/app/Access/SocialDriverManager.php new file mode 100644 index 00000000000..efafab560ce --- /dev/null +++ b/app/Access/SocialDriverManager.php @@ -0,0 +1,147 @@ + + */ + 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 + */ + 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); + } +} diff --git a/app/Access/UserInviteException.php b/app/Access/UserInviteException.php new file mode 100644 index 00000000000..70e7a787241 --- /dev/null +++ b/app/Access/UserInviteException.php @@ -0,0 +1,10 @@ +deleteByUser($user); + $token = $this->createTokenForUser($user); + + try { + $user->notify(new UserInviteNotification($token)); + } catch (\Exception $exception) { + throw new UserInviteException($exception->getMessage(), $exception->getCode(), $exception); + } + } +} diff --git a/app/Access/UserTokenService.php b/app/Access/UserTokenService.php new file mode 100644 index 00000000000..b4fd09c1b64 --- /dev/null +++ b/app/Access/UserTokenService.php @@ -0,0 +1,112 @@ +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)); + } +} diff --git a/app/Activity.php b/app/Activity.php deleted file mode 100644 index af386700afe..00000000000 --- a/app/Activity.php +++ /dev/null @@ -1,50 +0,0 @@ -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]; - } - -} diff --git a/app/Activity/ActivityQueries.php b/app/Activity/ActivityQueries.php new file mode 100644 index 00000000000..d5b047937fb --- /dev/null +++ b/app/Activity/ActivityQueries.php @@ -0,0 +1,117 @@ +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 $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 $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; + } +} diff --git a/app/Activity/ActivityType.php b/app/Activity/ActivityType.php new file mode 100644 index 00000000000..64532de175c --- /dev/null +++ b/app/Activity/ActivityType.php @@ -0,0 +1,86 @@ +getConstants(); + } +} diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php new file mode 100644 index 00000000000..1802e390585 --- /dev/null +++ b/app/Activity/CommentRepo.php @@ -0,0 +1,153 @@ +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 + */ + 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; + } +} diff --git a/app/Activity/Controllers/AuditLogApiController.php b/app/Activity/Controllers/AuditLogApiController.php new file mode 100644 index 00000000000..0cb4d9cb6da --- /dev/null +++ b/app/Activity/Controllers/AuditLogApiController.php @@ -0,0 +1,29 @@ +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', + ]); + } +} diff --git a/app/Activity/Controllers/AuditLogController.php b/app/Activity/Controllers/AuditLogController.php new file mode 100644 index 00000000000..ed1421c0d01 --- /dev/null +++ b/app/Activity/Controllers/AuditLogController.php @@ -0,0 +1,73 @@ +checkPermission(Permission::SettingsManage); + $this->checkPermission(Permission::UsersManage); + + $sort = $request->input('sort', 'activity_date'); + $order = $request->input('order', 'desc'); + $listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([ + 'created_at' => trans('settings.audit_table_date'), + 'type' => trans('settings.audit_table_event'), + ]); + + $filters = [ + 'event' => $request->input('event', ''), + 'date_from' => $request->input('date_from', ''), + 'date_to' => $request->input('date_to', ''), + 'user' => $request->input('user', ''), + 'ip' => $request->input('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'))) + ]); + } +} diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php new file mode 100644 index 00000000000..6c60de9da52 --- /dev/null +++ b/app/Activity/Controllers/CommentApiController.php @@ -0,0 +1,148 @@ + [ + '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); + } +} diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php new file mode 100644 index 00000000000..8474d9eb1c7 --- /dev/null +++ b/app/Activity/Controllers/CommentController.php @@ -0,0 +1,123 @@ +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->getVisibleById($commentId); + $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->getVisibleById($id); + 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->getVisibleById($id); + 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->getVisibleById($id); + $this->checkOwnablePermission(Permission::CommentDelete, $comment); + + $this->commentRepo->delete($comment); + + return response()->json(['message' => trans('entities.comment_deleted')]); + } +} diff --git a/app/Activity/Controllers/FavouriteController.php b/app/Activity/Controllers/FavouriteController.php new file mode 100644 index 00000000000..65bae276d28 --- /dev/null +++ b/app/Activity/Controllers/FavouriteController.php @@ -0,0 +1,72 @@ +input('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()); + } +} diff --git a/app/Activity/Controllers/TagApiController.php b/app/Activity/Controllers/TagApiController.php new file mode 100644 index 00000000000..f5c5e95d420 --- /dev/null +++ b/app/Activity/Controllers/TagApiController.php @@ -0,0 +1,68 @@ + [ + 'name' => ['required', 'string'], + ], + ]; + } + + /** + * Get a list of tag names used in the system. + * Only the name field can be used in filters. + */ + public function listNames(): JsonResponse + { + $tagQuery = $this->tagRepo + ->queryWithTotalsForApi(''); + + return $this->apiListingResponse($tagQuery, [ + 'name', 'values', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count', + ], [], [ + 'name' + ]); + } + + /** + * Get a list of tag values, which have been set for the given tag name, + * which must be provided as a query parameter on the request. + * Only the value field can be used in filters. + */ + public function listValues(Request $request): JsonResponse + { + $data = $this->validate($request, $this->rules()['listValues']); + $name = $data['name']; + + $tagQuery = $this->tagRepo->queryWithTotalsForApi($name); + + return $this->apiListingResponse($tagQuery, [ + 'name', 'value', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count', + ], [], [ + 'value', + ]); + } +} diff --git a/app/Activity/Controllers/TagController.php b/app/Activity/Controllers/TagController.php new file mode 100644 index 00000000000..b57c798254a --- /dev/null +++ b/app/Activity/Controllers/TagController.php @@ -0,0 +1,66 @@ +withSortOptions([ + 'name' => trans('common.sort_name'), + 'usages' => trans('entities.tags_usages'), + ]); + + $nameFilter = $request->input('name', ''); + $tags = $this->tagRepo + ->queryWithTotalsForList($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->input('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->input('search', ''); + $tagName = $request->input('name', ''); + $suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName); + + return response()->json($suggestions); + } +} diff --git a/app/Activity/Controllers/WatchController.php b/app/Activity/Controllers/WatchController.php new file mode 100644 index 00000000000..b77a893ea58 --- /dev/null +++ b/app/Activity/Controllers/WatchController.php @@ -0,0 +1,30 @@ +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()); + } +} diff --git a/app/Activity/Controllers/WebhookController.php b/app/Activity/Controllers/WebhookController.php new file mode 100644 index 00000000000..6a65b836361 --- /dev/null +++ b/app/Activity/Controllers/WebhookController.php @@ -0,0 +1,147 @@ +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'); + } +} diff --git a/app/Activity/DispatchWebhookJob.php b/app/Activity/DispatchWebhookJob.php new file mode 100644 index 00000000000..e1771b114cf --- /dev/null +++ b/app/Activity/DispatchWebhookJob.php @@ -0,0 +1,84 @@ +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(); + } +} diff --git a/app/Activity/Models/Activity.php b/app/Activity/Models/Activity.php new file mode 100644 index 00000000000..898a6c93a09 --- /dev/null +++ b/app/Activity/Models/Activity.php @@ -0,0 +1,80 @@ +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]; + } +} diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php new file mode 100644 index 00000000000..3faa76657b6 --- /dev/null +++ b/app/Activity/Models/Comment.php @@ -0,0 +1,112 @@ + '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 + */ + 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 + { + $filter = new HtmlContentFilter(new HtmlContentFilterConfig()); + return $filter->filterString($this->html ?? ''); + } + + public function getPlainText(): string + { + $converter = new HtmlToPlainText(); + return $converter->convert($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'); + } +} diff --git a/app/Activity/Models/Favouritable.php b/app/Activity/Models/Favouritable.php new file mode 100644 index 00000000000..7a2b9247a7b --- /dev/null +++ b/app/Activity/Models/Favouritable.php @@ -0,0 +1,13 @@ +morphTo(); + } + + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'favouritable_id') + ->whereColumn('favourites.favouritable_type', '=', 'joint_permissions.entity_type'); + } +} diff --git a/app/Activity/Models/Loggable.php b/app/Activity/Models/Loggable.php new file mode 100644 index 00000000000..f068b1f2c1c --- /dev/null +++ b/app/Activity/Models/Loggable.php @@ -0,0 +1,11 @@ +morphTo('entity'); + } + + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id') + ->whereColumn('tags.entity_type', '=', 'joint_permissions.entity_type'); + } + + /** + * Get a full URL to start a tag name search for this tag name. + */ + public function nameUrl(): string + { + return url('/search?term=%5B' . urlencode($this->name) . '%5D'); + } + + /** + * Get a full URL to start a tag name and value search for this tag's values. + */ + public function valueUrl(): string + { + return url('/search?term=%5B' . urlencode($this->name) . '%3D' . urlencode($this->value) . '%5D'); + } +} diff --git a/app/Activity/Models/View.php b/app/Activity/Models/View.php new file mode 100644 index 00000000000..30ead11935f --- /dev/null +++ b/app/Activity/Models/View.php @@ -0,0 +1,57 @@ +morphTo(); + } + + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'viewable_id') + ->whereColumn('views.viewable_type', '=', 'joint_permissions.entity_type'); + } + + /** + * Increment the current user's view count for the given viewable model. + */ + public static function incrementFor(Viewable $viewable): int + { + $user = user(); + if ($user->isGuest()) { + return 0; + } + + /** @var View $view */ + $view = $viewable->views()->firstOrNew([ + 'user_id' => $user->id, + ], ['views' => 0]); + + $view->forceFill(['views' => $view->views + 1])->save(); + + return $view->views; + } +} diff --git a/app/Activity/Models/Viewable.php b/app/Activity/Models/Viewable.php new file mode 100644 index 00000000000..ccb6d52969b --- /dev/null +++ b/app/Activity/Models/Viewable.php @@ -0,0 +1,13 @@ +morphTo(); + } + + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'watchable_id') + ->whereColumn('watches.watchable_type', '=', 'joint_permissions.entity_type'); + } + + public function getLevelName(): string + { + return WatchLevels::levelValueToName($this->level); + } + + public function ignoring(): bool + { + return $this->level === WatchLevels::IGNORE; + } +} diff --git a/app/Activity/Models/Webhook.php b/app/Activity/Models/Webhook.php new file mode 100644 index 00000000000..2a80c71a777 --- /dev/null +++ b/app/Activity/Models/Webhook.php @@ -0,0 +1,85 @@ + 'datetime', + 'last_errored_at' => 'datetime', + ]; + + /** + * Define the tracked event relation a webhook. + */ + public function trackedEvents(): HasMany + { + return $this->hasMany(WebhookTrackedEvent::class); + } + + /** + * Update the tracked events for a webhook from the given list of event types. + */ + public function updateTrackedEvents(array $events): void + { + $this->trackedEvents()->delete(); + + $eventsToStore = array_intersect($events, array_values(ActivityType::all())); + if (in_array('all', $events)) { + $eventsToStore = ['all']; + } + + $trackedEvents = []; + foreach ($eventsToStore as $event) { + $trackedEvents[] = new WebhookTrackedEvent(['event' => $event]); + } + + $this->trackedEvents()->saveMany($trackedEvents); + } + + /** + * Check if this webhook tracks the given event. + */ + public function tracksEvent(string $event): bool + { + return $this->trackedEvents->pluck('event')->contains($event); + } + + /** + * Get a URL for this webhook within the settings interface. + */ + public function getUrl(string $path = ''): string + { + return url('/settings/webhooks/' . $this->id . '/' . ltrim($path, '/')); + } + + /** + * Get the string descriptor for this item. + */ + public function logDescriptor(): string + { + return "({$this->id}) {$this->name}"; + } +} diff --git a/app/Activity/Models/WebhookTrackedEvent.php b/app/Activity/Models/WebhookTrackedEvent.php new file mode 100644 index 00000000000..ac38849e9e1 --- /dev/null +++ b/app/Activity/Models/WebhookTrackedEvent.php @@ -0,0 +1,18 @@ + $notification + * @param int[] $userIds + */ + protected function sendNotificationToUserIds(string $notification, array $userIds, User $initiator, string|Loggable $detail, Entity $relatedModel): void + { + $users = User::query()->whereIn('id', array_unique($userIds))->get(); + + /** @var User $user */ + foreach ($users as $user) { + // Prevent sending to the user that initiated the activity + if ($user->id === $initiator->id) { + continue; + } + + // Prevent sending of the user does not have notification permissions + if (!$user->can(Permission::ReceiveNotifications)) { + continue; + } + + // Prevent sending if the user does not have access to the related content + $permissions = new PermissionApplicator($user); + if (!$permissions->checkOwnableUserAccess($relatedModel, 'view')) { + continue; + } + + // Send the notification + try { + $user->notify(new $notification($detail, $initiator)); + } catch (\Exception $exception) { + Log::error("Failed to send email notification to user [id:{$user->id}] with error: {$exception->getMessage()}"); + } + } + } +} diff --git a/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php b/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php new file mode 100644 index 00000000000..daacfba5679 --- /dev/null +++ b/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php @@ -0,0 +1,48 @@ +entity; + $watchers = new EntityWatchers($page, WatchLevels::COMMENTS); + $watcherIds = $watchers->getWatcherUserIds(); + + // Page owner if user preferences allow + if ($page->owned_by && !$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) { + $userNotificationPrefs = new UserNotificationPreferences($page->ownedBy); + if ($userNotificationPrefs->notifyOnOwnPageComments()) { + $watcherIds[] = $page->owned_by; + } + } + + // Parent comment creator if preferences allow + $parentComment = $detail->parent()->first(); + if ($parentComment && $parentComment->created_by && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) { + $parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy); + if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) { + $watcherIds[] = $parentComment->created_by; + } + } + + $this->sendNotificationToUserIds(CommentCreationNotification::class, $watcherIds, $user, $detail, $page); + } +} diff --git a/app/Activity/Notifications/Handlers/CommentMentionNotificationHandler.php b/app/Activity/Notifications/Handlers/CommentMentionNotificationHandler.php new file mode 100644 index 00000000000..50c8cb8ab59 --- /dev/null +++ b/app/Activity/Notifications/Handlers/CommentMentionNotificationHandler.php @@ -0,0 +1,85 @@ +entity instanceof Page)) { + throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page"); + } + + /** @var Page $page */ + $page = $detail->entity; + + $parser = new MentionParser(); + $mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html); + $realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get(); + + $receivingNotifications = $realMentionedUsers->filter(function (User $user) { + $prefs = new UserNotificationPreferences($user); + return $prefs->notifyOnCommentMentions(); + }); + $receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray(); + + $userMentionsToLog = $realMentionedUsers; + + // When an edit, we check our history to see if we've already notified the user about this comment before + // so that we can filter them out to avoid double notifications. + if ($activity->type === ActivityType::COMMENT_UPDATE) { + $previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail); + $receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds)); + $userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) { + return !in_array($user->id, $previouslyNotifiedUserIds); + }); + } + + $this->logMentions($userMentionsToLog, $detail, $user); + $this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page); + } + + /** + * @param Collection $mentionedUsers + */ + protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void + { + $mentions = []; + $now = Carbon::now(); + + foreach ($mentionedUsers as $mentionedUser) { + $mentions[] = [ + 'mentionable_type' => $comment->getMorphClass(), + 'mentionable_id' => $comment->id, + 'from_user_id' => $fromUser->id, + 'to_user_id' => $mentionedUser->id, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + MentionHistory::query()->insert($mentions); + } + + protected function getPreviouslyNotifiedUserIds(Comment $comment): array + { + return MentionHistory::query() + ->where('mentionable_id', $comment->id) + ->where('mentionable_type', $comment->getMorphClass()) + ->pluck('to_user_id') + ->toArray(); + } +} diff --git a/app/Activity/Notifications/Handlers/NotificationHandler.php b/app/Activity/Notifications/Handlers/NotificationHandler.php new file mode 100644 index 00000000000..8c5498664e1 --- /dev/null +++ b/app/Activity/Notifications/Handlers/NotificationHandler.php @@ -0,0 +1,17 @@ +sendNotificationToUserIds(PageCreationNotification::class, $watchers->getWatcherUserIds(), $user, $detail, $detail); + } +} diff --git a/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php b/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php new file mode 100644 index 00000000000..c9489d70e63 --- /dev/null +++ b/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php @@ -0,0 +1,52 @@ +activity() + ->where('type', '=', ActivityType::PAGE_UPDATE) + ->where('id', '!=', $activity->id) + ->latest('created_at') + ->first(); + + // Return if the same user has already updated the page in the last 15 mins + if ($lastUpdate && $lastUpdate->user_id === $user->id) { + if ($lastUpdate->created_at->gt(now()->subMinutes(15))) { + return; + } + } + + // Get active watchers + $watchers = new EntityWatchers($detail, WatchLevels::UPDATES); + $watcherIds = $watchers->getWatcherUserIds(); + + // Add the page owner if preferences allow + if ($detail->owned_by && !$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) { + $userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy); + if ($userNotificationPrefs->notifyOnOwnPageChanges()) { + $watcherIds[] = $detail->owned_by; + } + } + + $this->sendNotificationToUserIds(PageUpdateNotification::class, $watcherIds, $user, $detail, $detail); + } +} diff --git a/app/Activity/Notifications/MessageParts/EntityLinkMessageLine.php b/app/Activity/Notifications/MessageParts/EntityLinkMessageLine.php new file mode 100644 index 00000000000..599833cce08 --- /dev/null +++ b/app/Activity/Notifications/MessageParts/EntityLinkMessageLine.php @@ -0,0 +1,29 @@ +entity->getUrl()) . '">' . e($this->entity->getShortName($this->nameLength)) . ''; + } + + public function __toString(): string + { + return "{$this->entity->getShortName($this->nameLength)} ({$this->entity->getUrl()})"; + } +} diff --git a/app/Activity/Notifications/MessageParts/EntityPathMessageLine.php b/app/Activity/Notifications/MessageParts/EntityPathMessageLine.php new file mode 100644 index 00000000000..4b0f6e6cf8e --- /dev/null +++ b/app/Activity/Notifications/MessageParts/EntityPathMessageLine.php @@ -0,0 +1,35 @@ +entityLinks = array_map(fn (Entity $entity) => new EntityLinkMessageLine($entity, 24), $this->entities); + } + + public function toHtml(): string + { + $entityHtmls = array_map(fn (EntityLinkMessageLine $line) => $line->toHtml(), $this->entityLinks); + return implode(' > ', $entityHtmls); + } + + public function __toString(): string + { + return implode(' > ', $this->entityLinks); + } +} diff --git a/app/Activity/Notifications/MessageParts/LinkedMailMessageLine.php b/app/Activity/Notifications/MessageParts/LinkedMailMessageLine.php new file mode 100644 index 00000000000..45ae825710c --- /dev/null +++ b/app/Activity/Notifications/MessageParts/LinkedMailMessageLine.php @@ -0,0 +1,33 @@ +url) . '">' . e($this->linkText) . ''; + return str_replace(':link', $link, e($this->line)); + } + + public function __toString(): string + { + $link = "{$this->linkText} ({$this->url})"; + return str_replace(':link', $link, $this->line); + } +} diff --git a/app/Activity/Notifications/MessageParts/ListMessageLine.php b/app/Activity/Notifications/MessageParts/ListMessageLine.php new file mode 100644 index 00000000000..9a729aa228c --- /dev/null +++ b/app/Activity/Notifications/MessageParts/ListMessageLine.php @@ -0,0 +1,36 @@ +list as $header => $content) { + $list[] = '' . e($header) . ' ' . e($content); + } + return implode("
\n", $list); + } + + public function __toString(): string + { + $list = []; + foreach ($this->list as $header => $content) { + $list[] = $header . ' ' . $content; + } + return implode("\n", $list); + } +} diff --git a/app/Activity/Notifications/Messages/BaseActivityNotification.php b/app/Activity/Notifications/Messages/BaseActivityNotification.php new file mode 100644 index 00000000000..067cd8f66e6 --- /dev/null +++ b/app/Activity/Notifications/Messages/BaseActivityNotification.php @@ -0,0 +1,67 @@ + $this->detail, + 'activity_creator' => $this->user, + ]; + } + + /** + * Build the common reason footer line used in mail messages. + */ + protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine + { + return new LinkedMailMessageLine( + url('/my-account/notifications'), + $locale->trans('notifications.footer_reason'), + $locale->trans('notifications.footer_reason_link'), + ); + } + + /** + * Build a line which provides the book > chapter path to a page. + * Takes into account visibility of these parent items. + * Returns null if no path items can be used. + */ + protected function buildPagePathLine(Page $page, User $notifiable): ?EntityPathMessageLine + { + $permissions = new PermissionApplicator($notifiable); + + $path = array_filter([$page->book, $page->chapter], function (?Entity $entity) use ($permissions) { + return !is_null($entity) && $permissions->checkOwnableUserAccess($entity, 'view'); + }); + + return empty($path) ? null : new EntityPathMessageLine($path); + } +} diff --git a/app/Activity/Notifications/Messages/CommentCreationNotification.php b/app/Activity/Notifications/Messages/CommentCreationNotification.php new file mode 100644 index 00000000000..d739f4aabbf --- /dev/null +++ b/app/Activity/Notifications/Messages/CommentCreationNotification.php @@ -0,0 +1,37 @@ +detail; + /** @var Page $page */ + $page = $comment->entity; + + $locale = $notifiable->getLocale(); + + $listLines = array_filter([ + $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), + $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), + $locale->trans('notifications.detail_commenter') => $this->user->name, + $locale->trans('notifications.detail_comment') => $comment->getPlainText(), + ]); + + return $this->newMailMessage($locale) + ->subject($locale->trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()])) + ->line($locale->trans('notifications.new_comment_intro', ['appName' => setting('app-name')])) + ->line(new ListMessageLine($listLines)) + ->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id)) + ->line($this->buildReasonFooterLine($locale)); + } +} diff --git a/app/Activity/Notifications/Messages/CommentMentionNotification.php b/app/Activity/Notifications/Messages/CommentMentionNotification.php new file mode 100644 index 00000000000..4c8ee5bab8b --- /dev/null +++ b/app/Activity/Notifications/Messages/CommentMentionNotification.php @@ -0,0 +1,37 @@ +detail; + /** @var Page $page */ + $page = $comment->entity; + + $locale = $notifiable->getLocale(); + + $listLines = array_filter([ + $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), + $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), + $locale->trans('notifications.detail_commenter') => $this->user->name, + $locale->trans('notifications.detail_comment') => $comment->getPlainText(), + ]); + + return $this->newMailMessage($locale) + ->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()])) + ->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')])) + ->line(new ListMessageLine($listLines)) + ->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id)) + ->line($this->buildReasonFooterLine($locale)); + } +} diff --git a/app/Activity/Notifications/Messages/PageCreationNotification.php b/app/Activity/Notifications/Messages/PageCreationNotification.php new file mode 100644 index 00000000000..0b98ad30ce4 --- /dev/null +++ b/app/Activity/Notifications/Messages/PageCreationNotification.php @@ -0,0 +1,33 @@ +detail; + + $locale = $notifiable->getLocale(); + + $listLines = array_filter([ + $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), + $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), + $locale->trans('notifications.detail_created_by') => $this->user->name, + ]); + + return $this->newMailMessage($locale) + ->subject($locale->trans('notifications.new_page_subject', ['pageName' => $page->getShortName()])) + ->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')])) + ->line(new ListMessageLine($listLines)) + ->action($locale->trans('notifications.action_view_page'), $page->getUrl()) + ->line($this->buildReasonFooterLine($locale)); + } +} diff --git a/app/Activity/Notifications/Messages/PageUpdateNotification.php b/app/Activity/Notifications/Messages/PageUpdateNotification.php new file mode 100644 index 00000000000..80ee378ccd6 --- /dev/null +++ b/app/Activity/Notifications/Messages/PageUpdateNotification.php @@ -0,0 +1,34 @@ +detail; + + $locale = $notifiable->getLocale(); + + $listLines = array_filter([ + $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), + $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), + $locale->trans('notifications.detail_updated_by') => $this->user->name, + ]); + + return $this->newMailMessage($locale) + ->subject($locale->trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()])) + ->line($locale->trans('notifications.updated_page_intro', ['appName' => setting('app-name')])) + ->line(new ListMessageLine($listLines)) + ->line($locale->trans('notifications.updated_page_debounce')) + ->action($locale->trans('notifications.action_view_page'), $page->getUrl()) + ->line($this->buildReasonFooterLine($locale)); + } +} diff --git a/app/Activity/Notifications/NotificationManager.php b/app/Activity/Notifications/NotificationManager.php new file mode 100644 index 00000000000..38da2c552a5 --- /dev/null +++ b/app/Activity/Notifications/NotificationManager.php @@ -0,0 +1,55 @@ +[]> + */ + protected array $handlersByActivity = []; + + public function handle(Activity $activity, string|Loggable $detail, User $user): void + { + $activityType = $activity->type; + $handlersToRun = $this->handlersByActivity[$activityType] ?? []; + foreach ($handlersToRun as $handlerClass) { + /** @var NotificationHandler $handler */ + $handler = new $handlerClass(); + $handler->handle($activity, $detail, $user); + } + } + + /** + * @param class-string $handlerClass + */ + public function registerHandler(string $activityType, string $handlerClass): void + { + if (!isset($this->handlersByActivity[$activityType])) { + $this->handlersByActivity[$activityType] = []; + } + + if (!in_array($handlerClass, $this->handlersByActivity[$activityType])) { + $this->handlersByActivity[$activityType][] = $handlerClass; + } + } + + public function loadDefaultHandlers(): void + { + $this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class); + $this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class); + $this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class); + $this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class); + $this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class); + } +} diff --git a/app/Activity/Queries/WebhooksAllPaginatedAndSorted.php b/app/Activity/Queries/WebhooksAllPaginatedAndSorted.php new file mode 100644 index 00000000000..0f23343a48a --- /dev/null +++ b/app/Activity/Queries/WebhooksAllPaginatedAndSorted.php @@ -0,0 +1,30 @@ +select(['*']) + ->withCount(['trackedEvents']) + ->orderBy($listOptions->getSort(), $listOptions->getOrder()); + + if ($listOptions->getSearch()) { + $term = '%' . $listOptions->getSearch() . '%'; + $query->where(function ($query) use ($term) { + $query->where('name', 'like', $term) + ->orWhere('endpoint', 'like', $term); + }); + } + + return $query->paginate($count); + } +} diff --git a/app/Activity/TagRepo.php b/app/Activity/TagRepo.php new file mode 100644 index 00000000000..3e8d5545ab6 --- /dev/null +++ b/app/Activity/TagRepo.php @@ -0,0 +1,156 @@ +getSearch(); + $sort = $listOptions->getSort(); + if ($sort === 'name' && $nameFilter) { + $sort = 'value'; + } + + $query = $this->baseQueryWithTotals($nameFilter, $searchTerm) + ->orderBy($sort, $listOptions->getOrder()); + + return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); + } + + /** + * Start a query against all tags in the system, with total counts for their usage, + * which can be used via the API. + */ + public function queryWithTotalsForApi(string $nameFilter): Builder + { + $query = $this->baseQueryWithTotals($nameFilter, ''); + return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); + } + + protected function baseQueryWithTotals(string $nameFilter, string $searchTerm): Builder + { + $query = Tag::query() + ->select([ + 'name', + ($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'), + DB::raw('COUNT(id) as usages'), + DB::raw('CAST(SUM(IF(entity_type = \'page\', 1, 0)) as UNSIGNED) as page_count'), + DB::raw('CAST(SUM(IF(entity_type = \'chapter\', 1, 0)) as UNSIGNED) as chapter_count'), + DB::raw('CAST(SUM(IF(entity_type = \'book\', 1, 0)) as UNSIGNED) as book_count'), + DB::raw('CAST(SUM(IF(entity_type = \'bookshelf\', 1, 0)) as UNSIGNED) as shelf_count'), + ]) + ->whereHas('entity'); + + if ($nameFilter) { + $query->where('name', '=', $nameFilter); + $query->groupBy('value'); + } elseif ($searchTerm) { + $query->groupBy('name', 'value'); + } else { + $query->groupBy('name'); + } + + if ($searchTerm) { + $query->where(function (Builder $query) use ($searchTerm) { + $query->where('name', 'like', '%' . $searchTerm . '%') + ->orWhere('value', 'like', '%' . $searchTerm . '%'); + }); + } + + return $query; + } + + /** + * Get tag name suggestions from scanning existing tag names. + * If no search term is given the 50 most popular tag names are provided. + */ + public function getNameSuggestions(string $searchTerm): Collection + { + $query = Tag::query() + ->select('*', DB::raw('count(*) as count')) + ->groupBy('name'); + + if ($searchTerm) { + $query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'asc'); + } else { + $query = $query->orderBy('count', 'desc')->take(50); + } + + $query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); + + return $query->pluck('name'); + } + + /** + * Get tag value suggestions from scanning existing tag values. + * If no search is given the 50 most popular values are provided. + * Passing a tagName will only find values for a tags with a particular name. + */ + public function getValueSuggestions(string $searchTerm, string $tagName): Collection + { + $query = Tag::query() + ->select('*', DB::raw('count(*) as count')) + ->where('value', '!=', '') + ->groupBy('value'); + + if ($searchTerm) { + $query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc'); + } else { + $query = $query->orderBy('count', 'desc')->take(50); + } + + if ($tagName) { + $query = $query->where('name', '=', $tagName); + } + + $query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); + + return $query->pluck('value'); + } + + /** + * Save an array of tags to an entity. + */ + public function saveTagsToEntity(Entity $entity, array $tags = []): iterable + { + $entity->tags()->delete(); + + $newTags = collect($tags)->filter(function ($tag) { + return boolval(trim($tag['name'])); + })->map(function ($tag) { + return $this->newInstanceFromInput($tag); + })->all(); + + return $entity->tags()->saveMany($newTags); + } + + /** + * Create a new Tag instance from user input. + * Input must be an array with a 'name' and an optional 'value' key. + */ + protected function newInstanceFromInput(array $input): Tag + { + return new Tag([ + 'name' => trim($input['name']), + 'value' => trim($input['value'] ?? ''), + ]); + } +} diff --git a/app/Activity/Tools/ActivityLogger.php b/app/Activity/Tools/ActivityLogger.php new file mode 100644 index 00000000000..415d1108494 --- /dev/null +++ b/app/Activity/Tools/ActivityLogger.php @@ -0,0 +1,115 @@ +notifications->loadDefaultHandlers(); + } + + /** + * Add a generic activity event to the database. + */ + public function add(string $type, string|Loggable $detail = ''): void + { + $detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail; + + $activity = $this->newActivityForUser($type); + $activity->detail = $detailToStore; + + if ($detail instanceof Entity) { + $activity->loggable_id = $detail->id; + $activity->loggable_type = $detail->getMorphClass(); + } + + $activity->save(); + + $this->setNotification($type); + $this->dispatchWebhooks($type, $detail); + $this->notifications->handle($activity, $detail, user()); + Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail); + } + + /** + * Get a new activity instance for the current user. + */ + protected function newActivityForUser(string $type): Activity + { + return (new Activity())->forceFill([ + 'type' => strtolower($type), + 'user_id' => user()->id, + 'ip' => IpFormatter::fromCurrentRequest()->format(), + ]); + } + + /** + * Removes the entity attachment from each of its activities + * and instead uses the 'extra' field with the entities name. + * Used when an entity is deleted. + */ + public function removeEntity(Entity $entity): void + { + $entity->activity()->update([ + 'detail' => $entity->name, + 'loggable_id' => null, + 'loggable_type' => null, + ]); + } + + /** + * Flashes a notification message to the session if an appropriate message is available. + */ + protected function setNotification(string $type): void + { + $notificationTextKey = 'activities.' . $type . '_notification'; + if (trans()->has($notificationTextKey)) { + $message = trans($notificationTextKey); + session()->flash('success', $message); + } + } + + protected function dispatchWebhooks(string $type, string|Loggable $detail): void + { + $webhooks = Webhook::query() + ->whereHas('trackedEvents', function (Builder $query) use ($type) { + $query->where('event', '=', $type) + ->orWhere('event', '=', 'all'); + }) + ->where('active', '=', true) + ->get(); + + foreach ($webhooks as $webhook) { + dispatch(new DispatchWebhookJob($webhook, $type, $detail)); + } + } + + /** + * Log out a failed login attempt, Providing the given username + * as part of the message if the '%u' string is used. + */ + public function logFailedLogin(string $username): void + { + $message = config('logging.failed_login.message'); + if (!$message) { + return; + } + + $message = str_replace('%u', $username, $message); + $channel = config('logging.failed_login.channel'); + Log::channel($channel)->warning($message); + } +} diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php new file mode 100644 index 00000000000..68f4a94d34d --- /dev/null +++ b/app/Activity/Tools/CommentTree.php @@ -0,0 +1,153 @@ +comments = $this->loadComments(); + $this->tree = $this->createTree($this->comments); + } + + public function enabled(): bool + { + return !setting('app-disable-comments'); + } + + public function empty(): bool + { + return count($this->getActive()) === 0; + } + + public function count(): int + { + return count($this->comments); + } + + public function getActive(): array + { + return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived)); + } + + public function activeThreadCount(): int + { + return count($this->getActive()); + } + + public function getArchived(): array + { + return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived)); + } + + public function archivedThreadCount(): int + { + return count($this->getArchived()); + } + + public function getCommentNodeForId(int $commentId): ?CommentTreeNode + { + foreach ($this->tree as $node) { + if ($node->comment->id === $commentId) { + return $node; + } + } + + return null; + } + + public function canUpdateAny(): bool + { + foreach ($this->comments as $comment) { + if (userCan(Permission::CommentUpdate, $comment)) { + return true; + } + } + + return false; + } + + public function loadVisibleHtml(): void + { + foreach ($this->comments as $comment) { + $comment->setAttribute('html', $comment->safeHtml()); + $comment->makeVisible('html'); + } + } + + /** + * @param Comment[] $comments + * @return CommentTreeNode[] + */ + protected function createTree(array $comments): array + { + $byId = []; + foreach ($comments as $comment) { + $byId[$comment->local_id] = $comment; + } + + $childMap = []; + foreach ($comments as $comment) { + $parent = $comment->parent_id; + if (is_null($parent) || !isset($byId[$parent])) { + $parent = 0; + } + + if (!isset($childMap[$parent])) { + $childMap[$parent] = []; + } + $childMap[$parent][] = $comment->local_id; + } + + $tree = []; + foreach ($childMap[0] ?? [] as $childId) { + $tree[] = $this->createTreeNodeForId($childId, 0, $byId, $childMap); + } + + return $tree; + } + + protected function createTreeNodeForId(int $id, int $depth, array &$byId, array &$childMap): CommentTreeNode + { + $childIds = $childMap[$id] ?? []; + $children = []; + + foreach ($childIds as $childId) { + $children[] = $this->createTreeNodeForId($childId, $depth + 1, $byId, $childMap); + } + + return new CommentTreeNode($byId[$id], $depth, $children); + } + + /** + * @return Comment[] + */ + protected function loadComments(): array + { + if (!$this->enabled()) { + return []; + } + + return $this->page->comments() + ->with('createdBy') + ->get() + ->all(); + } +} diff --git a/app/Activity/Tools/CommentTreeNode.php b/app/Activity/Tools/CommentTreeNode.php new file mode 100644 index 00000000000..7b280bd2d95 --- /dev/null +++ b/app/Activity/Tools/CommentTreeNode.php @@ -0,0 +1,23 @@ +comment = $comment; + $this->depth = $depth; + $this->children = $children; + } +} diff --git a/app/Activity/Tools/EntityWatchers.php b/app/Activity/Tools/EntityWatchers.php new file mode 100644 index 00000000000..1ab53cb1ce0 --- /dev/null +++ b/app/Activity/Tools/EntityWatchers.php @@ -0,0 +1,86 @@ +build(); + } + + public function getWatcherUserIds(): array + { + return $this->watchers; + } + + public function isUserIgnoring(int $userId): bool + { + return in_array($userId, $this->ignorers); + } + + protected function build(): void + { + $watches = $this->getRelevantWatches(); + + // Sort before de-duping, so that the order looped below follows book -> chapter -> page ordering + usort($watches, function (Watch $watchA, Watch $watchB) { + $entityTypeDiff = $watchA->watchable_type <=> $watchB->watchable_type; + return $entityTypeDiff === 0 ? ($watchA->user_id <=> $watchB->user_id) : $entityTypeDiff; + }); + + // De-dupe by user id to get their most relevant level + $levelByUserId = []; + foreach ($watches as $watch) { + $levelByUserId[$watch->user_id] = $watch->level; + } + + // Populate the class arrays + $this->watchers = array_keys(array_filter($levelByUserId, fn(int $level) => $level >= $this->watchLevel)); + $this->ignorers = array_keys(array_filter($levelByUserId, fn(int $level) => $level === 0)); + } + + /** + * @return Watch[] + */ + protected function getRelevantWatches(): array + { + /** @var Entity[] $entitiesInvolved */ + $entitiesInvolved = array_filter([ + $this->entity, + $this->entity instanceof BookChild ? $this->entity->book : null, + $this->entity instanceof Page ? $this->entity->chapter : null, + ]); + + $query = Watch::query()->where(function (Builder $query) use ($entitiesInvolved) { + foreach ($entitiesInvolved as $entity) { + $query->orWhere(function (Builder $query) use ($entity) { + $query->where('watchable_type', '=', $entity->getMorphClass()) + ->where('watchable_id', '=', $entity->id); + }); + } + }); + + return $query->get([ + 'level', 'watchable_id', 'watchable_type', 'user_id' + ])->all(); + } +} diff --git a/app/Activity/Tools/IpFormatter.php b/app/Activity/Tools/IpFormatter.php new file mode 100644 index 00000000000..13b56e99485 --- /dev/null +++ b/app/Activity/Tools/IpFormatter.php @@ -0,0 +1,81 @@ +ip = trim($ip); + $this->precision = max(0, min($precision, 4)); + } + + public function format(): string + { + if (empty($this->ip) || $this->precision === 4) { + return $this->ip; + } + + return $this->isIpv6() ? $this->maskIpv6() : $this->maskIpv4(); + } + + protected function maskIpv4(): string + { + $exploded = $this->explodeAndExpandIp('.', 4); + $maskGroupCount = min(4 - $this->precision, count($exploded)); + + for ($i = 0; $i < $maskGroupCount; $i++) { + $exploded[3 - $i] = 'x'; + } + + return implode('.', $exploded); + } + + protected function maskIpv6(): string + { + $exploded = $this->explodeAndExpandIp(':', 8); + $maskGroupCount = min(8 - ($this->precision * 2), count($exploded)); + + for ($i = 0; $i < $maskGroupCount; $i++) { + $exploded[7 - $i] = 'x'; + } + + return implode(':', $exploded); + } + + protected function isIpv6(): bool + { + return strpos($this->ip, ':') !== false; + } + + protected function explodeAndExpandIp(string $separator, int $targetLength): array + { + $exploded = explode($separator, $this->ip); + + while (count($exploded) < $targetLength) { + $emptyIndex = array_search('', $exploded) ?: count($exploded) - 1; + array_splice($exploded, $emptyIndex, 0, '0'); + } + + $emptyIndex = array_search('', $exploded); + if ($emptyIndex !== false) { + $exploded[$emptyIndex] = '0'; + } + + return $exploded; + } + + public static function fromCurrentRequest(): self + { + $ip = request()->ip() ?? ''; + + if (config('app.env') === 'demo') { + $ip = '127.0.0.1'; + } + + return new self($ip, config('app.ip_address_precision')); + } +} diff --git a/app/Activity/Tools/MentionParser.php b/app/Activity/Tools/MentionParser.php new file mode 100644 index 00000000000..d7bcac5e640 --- /dev/null +++ b/app/Activity/Tools/MentionParser.php @@ -0,0 +1,28 @@ +queryXPath('//a[@data-mention-user-id]'); + + foreach ($mentionLinks as $link) { + if ($link instanceof DOMElement) { + $id = intval($link->getAttribute('data-mention-user-id')); + if ($id > 0) { + $ids[] = $id; + } + } + } + + return array_values(array_unique($ids)); + } +} diff --git a/app/Activity/Tools/TagClassGenerator.php b/app/Activity/Tools/TagClassGenerator.php new file mode 100644 index 00000000000..0f7aa1fe0c4 --- /dev/null +++ b/app/Activity/Tools/TagClassGenerator.php @@ -0,0 +1,75 @@ +entity->tags->all(); + + foreach ($tags as $tag) { + array_push($classes, ...$this->generateClassesForTag($tag)); + } + + if ($this->entity instanceof BookChild && userCan(Permission::BookView, $this->entity->book)) { + $bookTags = $this->entity->book->tags; + foreach ($bookTags as $bookTag) { + array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-')); + } + } + + if ($this->entity instanceof Page && $this->entity->chapter && userCan(Permission::ChapterView, $this->entity->chapter)) { + $chapterTags = $this->entity->chapter->tags; + foreach ($chapterTags as $chapterTag) { + array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-')); + } + } + + return array_unique($classes); + } + + public function generateAsString(): string + { + return implode(' ', $this->generate()); + } + + /** + * @return string[] + */ + protected function generateClassesForTag(Tag $tag, string $prefix = ''): array + { + $classes = []; + $name = $this->normalizeTagClassString($tag->name); + $value = $this->normalizeTagClassString($tag->value); + $classes[] = "{$prefix}tag-name-{$name}"; + if ($value) { + $classes[] = "{$prefix}tag-value-{$value}"; + $classes[] = "{$prefix}tag-pair-{$name}-{$value}"; + } + return $classes; + } + + protected function normalizeTagClassString(string $value): string + { + $value = str_replace(' ', '', strtolower($value)); + $value = str_replace('-', '', strtolower($value)); + + return $value; + } +} diff --git a/app/Activity/Tools/UserEntityWatchOptions.php b/app/Activity/Tools/UserEntityWatchOptions.php new file mode 100644 index 00000000000..8e5f70758af --- /dev/null +++ b/app/Activity/Tools/UserEntityWatchOptions.php @@ -0,0 +1,132 @@ +user->can(Permission::ReceiveNotifications) && !$this->user->isGuest(); + } + + public function getWatchLevel(): string + { + return WatchLevels::levelValueToName($this->getWatchLevelValue()); + } + + public function isWatching(): bool + { + return $this->getWatchLevelValue() !== WatchLevels::DEFAULT; + } + + public function getWatchedParent(): ?WatchedParentDetails + { + $watchMap = $this->getWatchMap(); + unset($watchMap[$this->entity->getMorphClass()]); + + if (isset($watchMap['chapter'])) { + return new WatchedParentDetails('chapter', $watchMap['chapter']); + } + + if (isset($watchMap['book'])) { + return new WatchedParentDetails('book', $watchMap['book']); + } + + return null; + } + + public function updateLevelByName(string $level): void + { + $levelValue = WatchLevels::levelNameToValue($level); + $this->updateLevelByValue($levelValue); + } + + public function updateLevelByValue(int $level): void + { + if ($level < 0) { + $this->remove(); + return; + } + + $this->updateLevel($level); + } + + public function getWatchMap(): array + { + if (!is_null($this->watchMap)) { + return $this->watchMap; + } + + $entities = [$this->entity]; + if ($this->entity instanceof BookChild) { + $entities[] = $this->entity->book; + } + if ($this->entity instanceof Page && $this->entity->chapter) { + $entities[] = $this->entity->chapter; + } + + $query = Watch::query() + ->where('user_id', '=', $this->user->id) + ->where(function (Builder $subQuery) use ($entities) { + foreach ($entities as $entity) { + $subQuery->orWhere(function (Builder $whereQuery) use ($entity) { + $whereQuery->where('watchable_type', '=', $entity->getMorphClass()) + ->where('watchable_id', '=', $entity->id); + }); + } + }); + + $this->watchMap = $query->get(['watchable_type', 'level']) + ->pluck('level', 'watchable_type') + ->toArray(); + + return $this->watchMap; + } + + protected function getWatchLevelValue() + { + return $this->getWatchMap()[$this->entity->getMorphClass()] ?? WatchLevels::DEFAULT; + } + + protected function updateLevel(int $levelValue): void + { + Watch::query()->updateOrCreate([ + 'watchable_id' => $this->entity->id, + 'watchable_type' => $this->entity->getMorphClass(), + 'user_id' => $this->user->id, + ], [ + 'level' => $levelValue, + ]); + $this->watchMap = null; + } + + protected function remove(): void + { + $this->entityQuery()->delete(); + $this->watchMap = null; + } + + protected function entityQuery(): Builder + { + return Watch::query()->where('watchable_id', '=', $this->entity->id) + ->where('watchable_type', '=', $this->entity->getMorphClass()) + ->where('user_id', '=', $this->user->id); + } +} diff --git a/app/Activity/Tools/WatchedParentDetails.php b/app/Activity/Tools/WatchedParentDetails.php new file mode 100644 index 00000000000..5a881c04fb8 --- /dev/null +++ b/app/Activity/Tools/WatchedParentDetails.php @@ -0,0 +1,19 @@ +level === WatchLevels::IGNORE; + } +} diff --git a/app/Activity/Tools/WebhookFormatter.php b/app/Activity/Tools/WebhookFormatter.php new file mode 100644 index 00000000000..cb4e9cb0a9c --- /dev/null +++ b/app/Activity/Tools/WebhookFormatter.php @@ -0,0 +1,120 @@ +webhook = $webhook; + $this->event = $event; + $this->initiator = $initiator; + $this->initiatedTime = $initiatedTime; + $this->detail = is_object($detail) ? clone $detail : $detail; + } + + public function format(): array + { + $data = [ + 'event' => $this->event, + 'text' => $this->formatText(), + 'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(), + 'triggered_by' => $this->initiator->attributesToArray(), + 'triggered_by_profile_url' => $this->initiator->getProfileUrl(), + 'webhook_id' => $this->webhook->id, + 'webhook_name' => $this->webhook->name, + ]; + + if (method_exists($this->detail, 'getUrl')) { + $data['url'] = $this->detail->getUrl(); + } + + if ($this->detail instanceof Model) { + $data['related_item'] = $this->formatModel($this->detail); + } + + return $data; + } + + /** + * @param callable(string, Model):bool $condition + * @param callable(Model):void $format + */ + public function addModelFormatter(callable $condition, callable $format): void + { + $this->modelFormatters[] = [ + 'condition' => $condition, + 'format' => $format, + ]; + } + + public function addDefaultModelFormatters(): void + { + // Load entity owner, creator, updater details + $this->addModelFormatter( + fn ($event, $model) => ($model instanceof Entity), + fn ($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy']) + ); + + // Load revision detail for page update and create events + $this->addModelFormatter( + fn ($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)), + fn ($model) => $model->load('currentRevision') + ); + } + + protected function formatModel(Model $model): array + { + $model->unsetRelations(); + + foreach ($this->modelFormatters as $formatter) { + if ($formatter['condition']($this->event, $model)) { + $formatter['format']($model); + } + } + + return $model->toArray(); + } + + protected function formatText(): string + { + $textParts = [ + $this->initiator->name, + trans('activities.' . $this->event), + ]; + + if ($this->detail instanceof Entity) { + $textParts[] = '"' . $this->detail->name . '"'; + } + + return implode(' ', $textParts); + } + + public static function getDefault(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime): self + { + $instance = new self($event, $webhook, $detail, $initiator, $initiatedTime); + $instance->addDefaultModelFormatters(); + + return $instance; + } +} diff --git a/app/Activity/WatchLevels.php b/app/Activity/WatchLevels.php new file mode 100644 index 00000000000..edbece2d371 --- /dev/null +++ b/app/Activity/WatchLevels.php @@ -0,0 +1,91 @@ + value array. + * @return array + */ + public static function all(): array + { + $options = []; + foreach ((new \ReflectionClass(static::class))->getConstants() as $name => $value) { + $options[strtolower($name)] = $value; + } + + return $options; + } + + /** + * Get the watch options suited for the given entity. + * @return array + */ + public static function allSuitedFor(Entity $entity): array + { + $options = static::all(); + + if ($entity instanceof Page) { + unset($options['new']); + } elseif ($entity instanceof Bookshelf) { + return []; + } + + return $options; + } + + /** + * Convert the given name to a level value. + * Defaults to default value if the level does not exist. + */ + public static function levelNameToValue(string $level): int + { + return static::all()[$level] ?? static::DEFAULT; + } + + /** + * Convert the given int level value to a level name. + * Defaults to 'default' level name if not existing. + */ + public static function levelValueToName(int $level): string + { + foreach (static::all() as $name => $value) { + if ($level === $value) { + return $name; + } + } + + return 'default'; + } +} diff --git a/app/Api/ApiDocsController.php b/app/Api/ApiDocsController.php new file mode 100644 index 00000000000..d88dba3bc2f --- /dev/null +++ b/app/Api/ApiDocsController.php @@ -0,0 +1,41 @@ +setPageTitle(trans('settings.users_api_tokens_docs')); + + return view('api-docs.index', [ + 'docs' => $docs, + ]); + } + + /** + * Show a JSON view of the API docs data. + */ + public function json() + { + $docs = ApiDocsGenerator::generateConsideringCache(); + + return response()->json($docs); + } + + /** + * Redirect to the API docs page. + * Required as a controller method, instead of the Route::redirect helper, + * to ensure the URL is generated correctly. + */ + public function redirect() + { + return redirect('/api/docs'); + } +} diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php new file mode 100644 index 00000000000..53cb2890a7e --- /dev/null +++ b/app/Api/ApiDocsGenerator.php @@ -0,0 +1,216 @@ + + */ + protected array $reflectionClasses = []; + + /** + * @var array + */ + protected array $controllerClasses = []; + + /** + * Load the docs form the cache if existing + * otherwise generate and store in the cache. + */ + public static function generateConsideringCache(): Collection + { + $appVersion = AppVersion::get(); + $cacheKey = 'api-docs::' . $appVersion; + $isProduction = config('app.env') === 'production'; + $cacheVal = $isProduction ? Cache::get($cacheKey) : null; + + if (!is_null($cacheVal)) { + return $cacheVal; + } + + $docs = (new ApiDocsGenerator())->generate(); + Cache::put($cacheKey, $docs, 60 * 24); + + return $docs; + } + + /** + * Generate API documentation. + */ + protected function generate(): Collection + { + $apiRoutes = $this->getFlatApiRoutes(); + $apiRoutes = $this->loadDetailsFromControllers($apiRoutes); + $apiRoutes = $this->loadDetailsFromFiles($apiRoutes); + $apiRoutes = $apiRoutes->groupBy('base_model'); + + return $apiRoutes; + } + + /** + * Load any API details stored in static files. + */ + protected function loadDetailsFromFiles(Collection $routes): Collection + { + return $routes->map(function (array $route) { + $exampleTypes = ['request', 'response']; + $fileTypes = ['json', 'http']; + foreach ($exampleTypes as $exampleType) { + foreach ($fileTypes as $fileType) { + $exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}." . $fileType); + if (file_exists($exampleFile)) { + $route["example_{$exampleType}"] = file_get_contents($exampleFile); + continue 2; + } + } + $route["example_{$exampleType}"] = null; + } + + return $route; + }); + } + + /** + * Load any details we can fetch from the controller and its methods. + */ + protected function loadDetailsFromControllers(Collection $routes): Collection + { + return $routes->map(function (array $route) { + $class = $this->getReflectionClass($route['controller']); + $method = $this->getReflectionMethod($route['controller'], $route['controller_method']); + $comment = $method->getDocComment(); + $route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null; + $route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']); + + // Load class description for the model + // Not ideal to have it here on each route, but adding it in a more structured manner would break + // docs resulting JSON format and therefore be an API break. + // Save refactoring for a more significant set of changes. + $classComment = $class->getDocComment(); + $route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null; + + return $route; + }); + } + + /** + * Load body params and their rules by inspecting the given class and method name. + * + * @throws BindingResolutionException + */ + protected function getBodyParamsFromClass(string $className, string $methodName): ?array + { + $class = $this->controllerClasses[$className] ?? null; + if ($class === null) { + $class = app()->make($className); + $this->controllerClasses[$className] = $class; + } + + $rules = collect($class->getValidationRules()[$methodName] ?? [])->map(function ($validations) { + return array_map(function ($validation) { + return $this->getValidationAsString($validation); + }, $validations); + })->toArray(); + + return empty($rules) ? null : $rules; + } + + /** + * Convert the given validation message to a readable string. + */ + protected function getValidationAsString($validation): string + { + if (is_string($validation)) { + return $validation; + } + + if (is_object($validation) && method_exists($validation, '__toString')) { + return strval($validation); + } + + if ($validation instanceof Password) { + return 'min:8'; + } + + $class = get_class($validation); + + throw new Exception("Cannot provide string representation of rule for class: {$class}"); + } + + /** + * Parse out the description text from a class method comment. + */ + protected function parseDescriptionFromDocBlockComment(string $comment): string + { + $matches = []; + preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches); + + $text = implode(' ', $matches[1]); + return str_replace(' ', "\n", $text); + } + + /** + * Get a reflection method from the given class name and method name. + * + * @throws ReflectionException + */ + protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod + { + return $this->getReflectionClass($className)->getMethod($methodName); + } + + /** + * Get a reflection class from the given class name. + * + * @throws ReflectionException + */ + protected function getReflectionClass(string $className): ReflectionClass + { + $class = $this->reflectionClasses[$className] ?? null; + if ($class === null) { + $class = new ReflectionClass($className); + $this->reflectionClasses[$className] = $class; + } + + return $class; + } + + /** + * Get the system API routes, formatted into a flat collection. + */ + protected function getFlatApiRoutes(): Collection + { + return collect(Route::getRoutes()->getRoutes())->filter(function ($route) { + return str_starts_with($route->uri, 'api/'); + })->map(function ($route) { + [$controller, $controllerMethod] = explode('@', $route->action['uses']); + $baseModelName = explode('.', explode('/', $route->uri)[1])[0]; + $controllerMethodKebab = Str::kebab($controllerMethod); + $shortName = $baseModelName . '-' . $controllerMethodKebab; + + return [ + 'name' => $shortName, + 'uri' => $route->uri, + 'method' => $route->methods[0], + 'controller' => $controller, + 'controller_method' => $controllerMethod, + 'controller_method_kebab' => $controllerMethodKebab, + 'base_model' => $baseModelName, + ]; + }); + } +} diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php new file mode 100644 index 00000000000..23073bfc2fd --- /dev/null +++ b/app/Api/ApiEntityListFormatter.php @@ -0,0 +1,142 @@ + + */ + protected array $fields = [ + 'id', + 'name', + 'slug', + 'book_id', + 'chapter_id', + 'draft', + 'template', + 'priority', + 'created_at', + 'updated_at', + ]; + + public function __construct(array $list) + { + $this->list = $list; + + // Default dynamic fields + $this->withField('url', fn(Entity $entity) => $entity->getUrl()); + } + + /** + * Add a field to be used in the formatter, with the property using the given + * name and value being the return type of the given callback. + */ + public function withField(string $property, callable $callback): self + { + $this->fields[$property] = $callback; + return $this; + } + + /** + * Show the 'type' property in the response reflecting the entity type. + * EG: page, chapter, bookshelf, book + * To be included in results with non-pre-determined types. + */ + public function withType(): self + { + $this->withField('type', fn(Entity $entity) => $entity->getType()); + return $this; + } + + /** + * Include tags in the formatted data. + */ + public function withTags(): self + { + $this->withField('tags', fn(Entity $entity) => $entity->tags); + return $this; + } + + /** + * Include parent book/chapter info in the formatted data. + * These functions are careful to not load the relation themselves, since they should + * have already been loaded in a more efficient manner, with permissions applied, by the time + * the parent fields are handled here. + */ + public function withParents(): self + { + $this->withField('book', function (Entity $entity) { + if ($entity instanceof BookChild && $entity->relationLoaded('book') && $entity->getRelationValue('book')) { + return $entity->book->only(['id', 'name', 'slug']); + } + return null; + }); + + $this->withField('chapter', function (Entity $entity) { + if ($entity instanceof Page && $entity->relationLoaded('chapter') && $entity->getRelationValue('chapter')) { + return $entity->chapter->only(['id', 'name', 'slug']); + } + return null; + }); + + return $this; + } + + /** + * Format the data and return an array of formatted content. + * @return array[] + */ + public function format(): array + { + $results = []; + + foreach ($this->list as $item) { + $results[] = $this->formatSingle($item); + } + + return $results; + } + + /** + * Format a single entity item to a plain array. + */ + protected function formatSingle(Entity $entity): array + { + $result = []; + $values = (clone $entity)->toArray(); + + foreach ($this->fields as $field => $callback) { + if (is_string($callback)) { + $field = $callback; + if (!isset($values[$field])) { + continue; + } + $value = $values[$field]; + } else { + $value = $callback($entity); + if (is_null($value)) { + continue; + } + } + + $result[$field] = $value; + } + + return $result; + } +} diff --git a/app/Api/ApiToken.php b/app/Api/ApiToken.php new file mode 100644 index 00000000000..ca89c813ed0 --- /dev/null +++ b/app/Api/ApiToken.php @@ -0,0 +1,63 @@ + 'date:Y-m-d', + ]; + + /** + * Get the user that this token belongs to. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the default expiry value for an API token. + * Set to 100 years from now. + */ + public static function defaultExpiry(): string + { + return Carbon::now()->addYears(100)->format('Y-m-d'); + } + + /** + * {@inheritdoc} + */ + public function logDescriptor(): string + { + return "({$this->id}) {$this->name}; User: {$this->user->logDescriptor()}"; + } + + /** + * Get the URL for managing this token. + */ + public function getUrl(string $path = ''): string + { + return url("/api-tokens/{$this->user_id}/{$this->id}/" . trim($path, '/')); + } +} diff --git a/app/Api/ApiTokenGuard.php b/app/Api/ApiTokenGuard.php new file mode 100644 index 00000000000..f1a3f0dc883 --- /dev/null +++ b/app/Api/ApiTokenGuard.php @@ -0,0 +1,167 @@ +user)) { + return $this->user; + } + + $user = null; + + try { + $user = $this->getAuthorisedUserFromRequest(); + } catch (ApiAuthException $exception) { + $this->lastAuthException = $exception; + } + + $this->user = $user; + + return $user; + } + + /** + * Determine if the current user is authenticated. If not, throw an exception. + * + * @throws ApiAuthException + * + * @return \Illuminate\Contracts\Auth\Authenticatable + */ + public function authenticate() + { + if (!is_null($user = $this->user())) { + return $user; + } + + if ($this->lastAuthException) { + throw $this->lastAuthException; + } + + throw new ApiAuthException('Unauthorized'); + } + + /** + * Check the API token in the request and fetch a valid authorised user. + * + * @throws ApiAuthException + */ + protected function getAuthorisedUserFromRequest(): Authenticatable + { + $authToken = trim($this->request->headers->get('Authorization', '')); + $this->validateTokenHeaderValue($authToken); + + [$id, $secret] = explode(':', str_replace('Token ', '', $authToken)); + $token = ApiToken::query() + ->where('token_id', '=', $id) + ->with(['user'])->first(); + + $this->validateToken($token, $secret); + + if ($this->loginService->awaitingEmailConfirmation($token->user)) { + throw new ApiAuthException(trans('errors.email_confirmation_awaiting')); + } + + return $token->user; + } + + /** + * Validate the format of the token header value string. + * + * @throws ApiAuthException + */ + protected function validateTokenHeaderValue(string $authToken): void + { + if (empty($authToken)) { + throw new ApiAuthException(trans('errors.api_no_authorization_found')); + } + + if (!str_contains($authToken, ':') || !str_starts_with($authToken, 'Token ')) { + throw new ApiAuthException(trans('errors.api_bad_authorization_format')); + } + } + + /** + * Validate the given secret against the given token and ensure the token + * currently has access to the instance API. + * + * @throws ApiAuthException + */ + protected function validateToken(?ApiToken $token, string $secret): void + { + if ($token === null) { + throw new ApiAuthException(trans('errors.api_user_token_not_found')); + } + + if (!Hash::check($secret, $token->secret)) { + throw new ApiAuthException(trans('errors.api_incorrect_token_secret')); + } + + $now = Carbon::now(); + if ($token->expires_at <= $now) { + throw new ApiAuthException(trans('errors.api_user_token_expired'), 403); + } + + if (!$token->user->can(Permission::AccessApi)) { + throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403); + } + } + + /** + * {@inheritdoc} + */ + public function validate(array $credentials = []): bool + { + if (empty($credentials['id']) || empty($credentials['secret'])) { + return false; + } + + $token = ApiToken::query() + ->where('token_id', '=', $credentials['id']) + ->with(['user'])->first(); + + if ($token === null) { + return false; + } + + return Hash::check($credentials['secret'], $token->secret); + } + + /** + * "Log out" the currently authenticated user. + */ + public function logout(): void + { + $this->user = null; + } +} diff --git a/app/Api/ListingResponseBuilder.php b/app/Api/ListingResponseBuilder.php new file mode 100644 index 00000000000..6b9cfdd7d0d --- /dev/null +++ b/app/Api/ListingResponseBuilder.php @@ -0,0 +1,186 @@ + + */ + protected array $resultModifiers = []; + + /** + * @var array + */ + protected array $filterOperators = [ + 'eq' => '=', + 'ne' => '!=', + 'gt' => '>', + 'lt' => '<', + 'gte' => '>=', + 'lte' => '<=', + 'like' => 'like', + ]; + + /** + * ListingResponseBuilder constructor. + * The given fields will be forced visible within the model results. + */ + public function __construct(Builder $query, Request $request, array $fields) + { + $this->query = $query; + $this->request = $request; + $this->fields = $fields; + } + + /** + * Get the response from this builder. + */ + public function toResponse(): JsonResponse + { + $filteredQuery = $this->filterQuery($this->query); + + $total = $filteredQuery->getCountForPagination(); + $data = $this->fetchData($filteredQuery)->each(function ($model) { + foreach ($this->resultModifiers as $modifier) { + $modifier($model); + } + }); + + return response()->json([ + 'data' => $data, + 'total' => $total, + ]); + } + + /** + * Add a callback to modify each element of the results. + * + * @param (callable(Model): void) $modifier + */ + public function modifyResults(callable $modifier): void + { + $this->resultModifiers[] = $modifier; + } + + /** + * Limit filtering to just the given set of fields. + */ + public function setFilterableFields(array $fields): void + { + $this->filterableFields = $fields; + } + + /** + * Fetch the data to return within the response. + */ + protected function fetchData(Builder $query): Collection + { + $query = $this->countAndOffsetQuery($query); + $query = $this->sortQuery($query); + + return $query->get($this->fields); + } + + /** + * Apply any filtering operations found in the request. + */ + protected function filterQuery(Builder $query): Builder + { + $query = clone $query; + $requestFilters = $this->request->input('filter', []); + if (!is_array($requestFilters)) { + return $query; + } + + $queryFilters = collect($requestFilters)->map(function ($value, $key) { + return $this->requestFilterToQueryFilter($key, $value); + })->filter(function ($value) { + return !is_null($value); + })->values()->toArray(); + + return $query->where($queryFilters); + } + + /** + * Convert a request filter query key/value pair into a [field, op, value] where condition. + */ + protected function requestFilterToQueryFilter($fieldKey, $value): ?array + { + $splitKey = explode(':', $fieldKey); + $field = strtolower($splitKey[0]); + $filterOperator = $splitKey[1] ?? 'eq'; + + $filterFields = $this->filterableFields ?? $this->fields; + if (!in_array($field, $filterFields)) { + return null; + } + + if (!in_array($filterOperator, array_keys($this->filterOperators))) { + $filterOperator = 'eq'; + } + + $queryOperator = $this->filterOperators[$filterOperator]; + + return [$field, $queryOperator, $value]; + } + + /** + * Apply sorting operations to the query from given parameters + * otherwise falling back to the first given field, ascending. + */ + protected function sortQuery(Builder $query): Builder + { + $query = clone $query; + $defaultSortName = $this->fields[0]; + $direction = 'asc'; + + $sort = $this->request->input('sort', ''); + if (str_starts_with($sort, '-')) { + $direction = 'desc'; + } + + $sortName = ltrim($sort, '+- '); + if (!in_array($sortName, $this->fields)) { + $sortName = $defaultSortName; + } + + return $query->orderBy($sortName, $direction); + } + + /** + * Apply count and offset for paging, based on params from the request while falling + * back to system defined default, taking the max limit into account. + */ + protected function countAndOffsetQuery(Builder $query): Builder + { + $query = clone $query; + $offset = max(0, $this->request->input('offset', 0)); + $maxCount = config('api.max_item_count'); + $count = $this->request->input('count', config('api.default_item_count')); + $count = max(min($maxCount, $count), 1); + + return $query->skip($offset)->take($count); + } +} diff --git a/app/Api/UserApiTokenController.php b/app/Api/UserApiTokenController.php new file mode 100644 index 00000000000..2894ede3aa5 --- /dev/null +++ b/app/Api/UserApiTokenController.php @@ -0,0 +1,182 @@ +checkPermission(Permission::AccessApi); + $this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId); + $this->updateContext($request); + + $user = User::query()->findOrFail($userId); + + $this->setPageTitle(trans('settings.user_api_token_create')); + + return view('users.api-tokens.create', [ + 'user' => $user, + 'back' => $this->getRedirectPath($user), + ]); + } + + /** + * Store a new API token in the system. + */ + public function store(Request $request, int $userId) + { + $this->checkPermission(Permission::AccessApi); + $this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId); + + $this->validate($request, [ + 'name' => ['required', 'max:250'], + 'expires_at' => ['date_format:Y-m-d'], + ]); + + $user = User::query()->findOrFail($userId); + $secret = Str::random(32); + + $token = (new ApiToken())->forceFill([ + 'name' => $request->input('name'), + 'token_id' => Str::random(32), + 'secret' => Hash::make($secret), + 'user_id' => $user->id, + 'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(), + ]); + + while (ApiToken::query()->where('token_id', '=', $token->token_id)->exists()) { + $token->token_id = Str::random(32); + } + + $token->save(); + + session()->flash('api-token-secret:' . $token->id, $secret); + $this->logActivity(ActivityType::API_TOKEN_CREATE, $token); + + return redirect($token->getUrl()); + } + + /** + * Show the details for a user API token, with access to edit. + */ + public function edit(Request $request, int $userId, int $tokenId) + { + $this->updateContext($request); + + [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); + $secret = session()->pull('api-token-secret:' . $token->id, null); + + $this->setPageTitle(trans('settings.user_api_token')); + + return view('users.api-tokens.edit', [ + 'user' => $user, + 'token' => $token, + 'model' => $token, + 'secret' => $secret, + 'back' => $this->getRedirectPath($user), + ]); + } + + /** + * Update the API token. + */ + public function update(Request $request, int $userId, int $tokenId) + { + $this->validate($request, [ + 'name' => ['required', 'max:250'], + 'expires_at' => ['date_format:Y-m-d'], + ]); + + [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); + $token->fill([ + 'name' => $request->input('name'), + 'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(), + ])->save(); + + $this->logActivity(ActivityType::API_TOKEN_UPDATE, $token); + + return redirect($token->getUrl()); + } + + /** + * Show the delete view for this token. + */ + public function delete(int $userId, int $tokenId) + { + [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); + + $this->setPageTitle(trans('settings.user_api_token_delete')); + + return view('users.api-tokens.delete', [ + 'user' => $user, + 'token' => $token, + ]); + } + + /** + * Destroy a token from the system. + */ + public function destroy(int $userId, int $tokenId) + { + [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); + $token->delete(); + + $this->logActivity(ActivityType::API_TOKEN_DELETE, $token); + + return redirect($this->getRedirectPath($user)); + } + + /** + * Check the permission for the current user and return an array + * where the first item is the user in context and the second item is their + * API token in context. + */ + protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array + { + $this->checkPermissionOr(Permission::UsersManage, function () use ($userId) { + return $userId === user()->id && userCan(Permission::AccessApi); + }); + + $user = User::query()->findOrFail($userId); + $token = ApiToken::query()->where('user_id', '=', $user->id)->where('id', '=', $tokenId)->firstOrFail(); + + return [$user, $token]; + } + + /** + * Update the context for where the user is coming from to manage API tokens. + * (Track of location for correct return redirects) + */ + protected function updateContext(Request $request): void + { + $context = $request->query('context'); + if ($context) { + session()->put('api-token-context', $context); + } + } + + /** + * Get the redirect path for the current api token editing session. + * Attempts to recall the context of where the user is editing from. + */ + protected function getRedirectPath(User $relatedUser): string + { + $context = session()->get('api-token-context'); + if ($context === 'settings' || user()->id !== $relatedUser->id) { + return $relatedUser->getEditUrl('#api_tokens'); + } + + return url('/my-account/auth#api_tokens'); + } +} diff --git a/app/App/AppVersion.php b/app/App/AppVersion.php new file mode 100644 index 00000000000..af422f64114 --- /dev/null +++ b/app/App/AppVersion.php @@ -0,0 +1,24 @@ +basePath + . DIRECTORY_SEPARATOR + . 'app' + . DIRECTORY_SEPARATOR + . 'Config' + . ($path ? DIRECTORY_SEPARATOR . $path : $path); + } +} diff --git a/app/App/HomeController.php b/app/App/HomeController.php new file mode 100644 index 00000000000..00e2db3df43 --- /dev/null +++ b/app/App/HomeController.php @@ -0,0 +1,114 @@ +latest(10); + $draftPages = []; + + if ($this->isSignedIn()) { + $draftPages = $this->queries->pages->currentUserDraftsForList() + ->orderBy('updated_at', 'desc') + ->with('book') + ->take(6) + ->get(); + } + + $recentFactor = count($draftPages) > 0 ? 0.5 : 1; + $recents = $this->isSignedIn() ? + $recentlyViewed->run(12 * $recentFactor, 1) + : $this->queries->books->visibleForList()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get(); + $favourites = $topFavourites->run(6); + $recentlyUpdatedPages = $this->queries->pages->visibleForList() + ->where('draft', false) + ->orderBy('updated_at', 'desc') + ->take($favourites->count() > 0 ? 5 : 10) + ->get(); + + $homepageOptions = ['default', 'books', 'bookshelves', 'page']; + $homepageOption = setting('app-homepage-type', 'default'); + if (!in_array($homepageOption, $homepageOptions)) { + $homepageOption = 'default'; + } + + $commonData = [ + 'activity' => $activity, + 'recents' => $recents, + 'recentlyUpdatedPages' => $recentlyUpdatedPages, + 'draftPages' => $draftPages, + 'favourites' => $favourites, + ]; + + // Add required list ordering & sorting for books & shelves views. + if ($homepageOption === 'bookshelves' || $homepageOption === 'books') { + $key = $homepageOption; + $view = setting()->getForCurrentUser($key . '_view_type'); + $listOptions = SimpleListOptions::fromRequest($request, $key)->withSortOptions([ + 'name' => trans('common.sort_name'), + 'created_at' => trans('common.sort_created_at'), + 'updated_at' => trans('common.sort_updated_at'), + ]); + + $commonData = array_merge($commonData, [ + 'view' => $view, + 'listOptions' => $listOptions, + ]); + } + + if ($homepageOption === 'bookshelves') { + $shelves = $this->queries->shelves->visibleForListWithCover() + ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) + ->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000)); + $data = array_merge($commonData, ['shelves' => $shelves]); + + return view('home.shelves', $data); + } + + if ($homepageOption === 'books') { + $books = $this->queries->books->visibleForListWithCover() + ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) + ->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); + $data = array_merge($commonData, ['books' => $books]); + + return view('home.books', $data); + } + + if ($homepageOption === 'page') { + $homepageSetting = setting('app-homepage', '0:'); + $id = intval(explode(':', $homepageSetting)[0]); + /** @var Page $customHomepage */ + $customHomepage = $this->queries->pages->start()->where('draft', '=', false)->findOrFail($id); + $pageContent = new PageContent($customHomepage); + $customHomepage->html = $pageContent->render(false); + + return view('home.specific-page', array_merge($commonData, ['customHomepage' => $customHomepage])); + } + + return view('home.default', $commonData); + } +} diff --git a/app/App/MailNotification.php b/app/App/MailNotification.php new file mode 100644 index 00000000000..50b7f69a745 --- /dev/null +++ b/app/App/MailNotification.php @@ -0,0 +1,45 @@ + $locale ?? user()->getLocale()]; + + return (new MailMessage())->view([ + 'html' => 'vendor.notifications.email', + 'text' => 'vendor.notifications.email-plain', + ], $data); + } +} diff --git a/app/App/MetaController.php b/app/App/MetaController.php new file mode 100644 index 00000000000..a94334c5851 --- /dev/null +++ b/app/App/MetaController.php @@ -0,0 +1,77 @@ +view('misc.robots', ['allowRobots' => $allowRobots]) + ->header('Content-Type', 'text/plain'); + } + + /** + * Show the route for 404 responses. + */ + public function notFound() + { + return response()->view('errors.404', [], 404); + } + + /** + * Serve the application favicon. + * Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served + * directly by the webserver in the future. + */ + public function favicon(FaviconHandler $favicons) + { + $exists = $favicons->restoreOriginalIfNotExists(); + return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath()); + } + + /** + * Serve a PWA application manifest. + */ + public function pwaManifest(PwaManifestBuilder $manifestBuilder) + { + return response()->json($manifestBuilder->build()); + } + + /** + * Show license information for the application. + */ + public function licenses() + { + $this->setPageTitle(trans('settings.licenses')); + + return view('help.licenses', [ + 'license' => file_get_contents(base_path('LICENSE')), + 'phpLibData' => file_get_contents(base_path('dev/licensing/php-library-licenses.txt')), + 'jsLibData' => file_get_contents(base_path('dev/licensing/js-library-licenses.txt')), + ]); + } + + /** + * Show the view for /opensearch.xml. + */ + public function opensearch() + { + return response() + ->view('misc.opensearch') + ->header('Content-Type', 'application/opensearchdescription+xml'); + } +} diff --git a/app/App/Model.php b/app/App/Model.php new file mode 100644 index 00000000000..e1c7511c14f --- /dev/null +++ b/app/App/Model.php @@ -0,0 +1,19 @@ + BookStackExceptionHandlerPage::class, + ]; + + /** + * Custom singleton bindings to register. + * @var string[] + */ + public array $singletons = [ + 'activity' => ActivityLogger::class, + SettingService::class => SettingService::class, + SocialDriverManager::class => SocialDriverManager::class, + CspService::class => CspService::class, + HttpRequestService::class => HttpRequestService::class, + ]; + + /** + * Register any application services. + */ + public function register(): void + { + $this->app->singleton(PermissionApplicator::class, function ($app) { + return new PermissionApplicator(null); + }); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + // Set root URL + $appUrl = config('app.url'); + if ($appUrl) { + $isHttps = str_starts_with($appUrl, 'https://'); + URL::forceRootUrl($appUrl); + URL::forceScheme($isHttps ? 'https' : 'http'); + } + + // Set SMTP mail driver to use a local domain matching the app domain, + // which helps avoid defaulting to a 127.0.0.1 domain + if ($appUrl) { + $hostName = parse_url($appUrl, PHP_URL_HOST) ?: null; + config()->set('mail.mailers.smtp.local_domain', $hostName); + } + + // Allow longer string lengths after upgrade to utf8mb4 + Schema::defaultStringLength(191); + + // Set morph-map for our relations to friendlier aliases + Relation::enforceMorphMap([ + 'bookshelf' => Bookshelf::class, + 'book' => Book::class, + 'chapter' => Chapter::class, + 'page' => Page::class, + 'comment' => Comment::class, + ]); + } +} diff --git a/app/App/Providers/AuthServiceProvider.php b/app/App/Providers/AuthServiceProvider.php new file mode 100644 index 00000000000..8c71fee3ac3 --- /dev/null +++ b/app/App/Providers/AuthServiceProvider.php @@ -0,0 +1,73 @@ + Password::min(8)); + + // Custom guards + Auth::extend('api-token', function ($app, $name, array $config) { + return new ApiTokenGuard($app['request'], $app->make(LoginService::class)); + }); + + Auth::extend('ldap-session', function ($app, $name, array $config) { + $provider = Auth::createUserProvider($config['provider']); + + return new LdapSessionGuard( + $name, + $provider, + $app['session.store'], + $app[LdapService::class], + $app[RegistrationService::class] + ); + }); + + Auth::extend('async-external-session', function ($app, $name, array $config) { + $provider = Auth::createUserProvider($config['provider']); + + return new AsyncExternalBaseSessionGuard( + $name, + $provider, + $app['session.store'], + $app[RegistrationService::class] + ); + }); + } + + /** + * Register the application services. + */ + public function register(): void + { + Auth::provider('external-users', function () { + return new ExternalBaseUserProvider($this->app[UserRepo::class]); + }); + + // Bind and provide the default system user as a singleton to the app instance when needed. + // This effectively "caches" fetching the user at an app-instance level. + $this->app->singleton('users.default', function () { + return User::query()->where('system_name', '=', 'public')->first(); + }); + } +} diff --git a/app/App/Providers/EventServiceProvider.php b/app/App/Providers/EventServiceProvider.php new file mode 100644 index 00000000000..60e78efe0e9 --- /dev/null +++ b/app/App/Providers/EventServiceProvider.php @@ -0,0 +1,53 @@ +> + */ + protected $listen = [ + SocialiteWasCalled::class => [ + AzureExtendSocialite::class . '@handle', + OktaExtendSocialite::class . '@handle', + GitLabExtendSocialite::class . '@handle', + TwitchExtendSocialite::class . '@handle', + DiscordExtendSocialite::class . '@handle', + ], + ]; + + /** + * Register any events for your application. + */ + public function boot(): void + { + // + } + + /** + * Determine if events and listeners should be automatically discovered. + */ + public function shouldDiscoverEvents(): bool + { + return false; + } + + /** + * Overrides the registration of Laravel's default email verification system + */ + protected function configureEmailVerification(): void + { + // + } +} diff --git a/app/App/Providers/RouteServiceProvider.php b/app/App/Providers/RouteServiceProvider.php new file mode 100644 index 00000000000..97c3e7c770d --- /dev/null +++ b/app/App/Providers/RouteServiceProvider.php @@ -0,0 +1,96 @@ +configureRateLimiting(); + + $this->routes(function () { + $this->mapWebRoutes(); + $this->mapApiRoutes(); + }); + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + */ + protected function mapWebRoutes(): void + { + Route::group([ + 'middleware' => 'web', + 'namespace' => $this->namespace, + ], function (Router $router) { + require base_path('routes/web.php'); + Theme::dispatch(ThemeEvents::ROUTES_REGISTER_WEB, $router); + }); + + Route::group([ + 'middleware' => ['web', 'auth'], + ], function (Router $router) { + Theme::dispatch(ThemeEvents::ROUTES_REGISTER_WEB_AUTH, $router); + }); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + */ + protected function mapApiRoutes(): void + { + Route::group([ + 'middleware' => 'api', + 'namespace' => $this->namespace . '\Api', + 'prefix' => 'api', + ], function ($router) { + require base_path('routes/api.php'); + }); + } + + /** + * Configure the rate limiters for the application. + */ + protected function configureRateLimiting(): void + { + RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('public', function (Request $request) { + return Limit::perMinute(10)->by($request->ip()); + }); + + RateLimiter::for('exports', function (Request $request) { + $user = user(); + $attempts = $user->isGuest() ? 4 : 10; + $key = $user->isGuest() ? $request->ip() : $user->id; + return Limit::perMinute($attempts)->by($key); + }); + } +} diff --git a/app/App/Providers/ThemeServiceProvider.php b/app/App/Providers/ThemeServiceProvider.php new file mode 100644 index 00000000000..671e5e1df74 --- /dev/null +++ b/app/App/Providers/ThemeServiceProvider.php @@ -0,0 +1,51 @@ +app->singleton(ThemeService::class, fn ($app) => new ThemeService()); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + // Boot up the theme system + $themeService = $this->app->make(ThemeService::class); + $viewFactory = $this->app->make('view'); + $themeViews = new ThemeViews($viewFactory->getFinder()); + + // Use a custom include so that we can insert theme views before/after includes. + // This is done, even if no theme is active, so that view caching does not create problems + // when switching between themes or when switching a theme on/off. + $viewFactory->share('__themeViews', $themeViews); + Blade::directive('include', function ($expression) { + return "handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>"; + }); + + if (!$themeService->getTheme()) { + return; + } + + $themeService->loadModules(); + $themeService->readThemeActions(); + $themeService->dispatch(ThemeEvents::APP_BOOT, $this->app); + + $themeViews->registerViewPathsForTheme($themeService->getModules()); + $themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews); + } +} diff --git a/app/App/Providers/TranslationServiceProvider.php b/app/App/Providers/TranslationServiceProvider.php new file mode 100644 index 00000000000..b838129a601 --- /dev/null +++ b/app/App/Providers/TranslationServiceProvider.php @@ -0,0 +1,49 @@ +registerLoader(); + + // This is a tweak upon Laravel's based translation service registration to allow + // usage of a custom MessageSelector class + $this->app->singleton('translator', function ($app) { + $loader = $app['translation.loader']; + + // When registering the translator component, we'll need to set the default + // locale as well as the fallback locale. So, we'll grab the application + // configuration so we can easily get both of these values from there. + $locale = $app['config']['app.locale']; + + $trans = new Translator($loader, $locale); + $trans->setFallback($app['config']['app.fallback_locale']); + $trans->setSelector(new MessageSelector()); + + return $trans; + }); + } + + + + /** + * Register the translation line loader. + * Overrides the default register action from Laravel so a custom loader can be used. + */ + protected function registerLoader(): void + { + $this->app->singleton('translation.loader', function ($app) { + return new FileLoader($app['files'], $app['path.lang']); + }); + } +} diff --git a/app/App/Providers/ValidationRuleServiceProvider.php b/app/App/Providers/ValidationRuleServiceProvider.php new file mode 100644 index 00000000000..fc030263029 --- /dev/null +++ b/app/App/Providers/ValidationRuleServiceProvider.php @@ -0,0 +1,29 @@ +getClientOriginalExtension()); + + return ImageService::isExtensionSupported($extension); + }); + + Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) { + $cleanLinkName = strtolower(trim($value)); + $filter = new UrlFilter($cleanLinkName); + return $filter->isAllowed(); + }); + } +} diff --git a/app/App/Providers/ViewTweaksServiceProvider.php b/app/App/Providers/ViewTweaksServiceProvider.php new file mode 100644 index 00000000000..6771e513fa6 --- /dev/null +++ b/app/App/Providers/ViewTweaksServiceProvider.php @@ -0,0 +1,42 @@ +app->singleton(DateFormatter::class, function ($app) { + return new DateFormatter( + $app['config']->get('app.display_timezone'), + ); + }); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + // Set paginator to use bootstrap-style pagination + Paginator::useBootstrap(); + + // View Composers + View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class); + + // View Globals + View::share('dates', $this->app->make(DateFormatter::class)); + + // Custom blade view directives + Blade::directive('icon', function ($expression) { + return "toHtml(); ?>"; + }); + } +} diff --git a/app/App/PwaManifestBuilder.php b/app/App/PwaManifestBuilder.php new file mode 100644 index 00000000000..2dbaead1373 --- /dev/null +++ b/app/App/PwaManifestBuilder.php @@ -0,0 +1,64 @@ +getForCurrentUser('dark-mode-enabled'); + $appName = setting('app-name'); + + return [ + "name" => $appName, + "short_name" => $appName, + "start_url" => "./", + "scope" => "/", + "display" => "standalone", + "background_color" => $darkMode ? '#111111' : '#F2F2F2', + "description" => $appName, + "theme_color" => ($darkMode ? setting('app-color-dark') : setting('app-color')), + "launch_handler" => [ + "client_mode" => "focus-existing" + ], + "orientation" => "any", + "icons" => [ + [ + "src" => setting('app-icon-32') ?: url('/icon-32.png'), + "sizes" => "32x32", + "type" => "image/png" + ], + [ + "src" => setting('app-icon-64') ?: url('/icon-64.png'), + "sizes" => "64x64", + "type" => "image/png" + ], + [ + "src" => setting('app-icon-128') ?: url('/icon-128.png'), + "sizes" => "128x128", + "type" => "image/png" + ], + [ + "src" => setting('app-icon-180') ?: url('/icon-180.png'), + "sizes" => "180x180", + "type" => "image/png" + ], + [ + "src" => setting('app-icon') ?: url('/icon.png'), + "sizes" => "256x256", + "type" => "image/png" + ], + [ + "src" => url('favicon.ico'), + "sizes" => "48x48", + "type" => "image/vnd.microsoft.icon" + ], + ], + ]; + } +} diff --git a/app/App/SluggableInterface.php b/app/App/SluggableInterface.php new file mode 100644 index 00000000000..dd544f5ed21 --- /dev/null +++ b/app/App/SluggableInterface.php @@ -0,0 +1,13 @@ +json([ + 'version' => AppVersion::get(), + 'instance_id' => setting('instance-id'), + 'app_name' => setting('app-name'), + 'app_logo' => $logo, + 'base_url' => url('/'), + ]); + } +} diff --git a/app/App/helpers.php b/app/App/helpers.php new file mode 100644 index 00000000000..8f210ecafd4 --- /dev/null +++ b/app/App/helpers.php @@ -0,0 +1,94 @@ +user() ?: User::getGuest(); +} + +/** + * Check if the current user has a permission. If an ownable element + * is passed in the jointPermissions are checked against that particular item. + */ +function userCan(string|Permission $permission, ?Model $ownable = null): bool +{ + if (is_null($ownable)) { + return user()->can($permission); + } + + // Check permission on ownable item + $permissions = app()->make(PermissionApplicator::class); + + return $permissions->checkOwnableUserAccess($ownable, $permission); +} + +/** + * Check if the current user can perform the given action on any items in the system. + * Can be provided the class name of an entity to filter ability to that specific entity type. + */ +function userCanOnAny(string|Permission $action, string $entityClass = ''): bool +{ + $permissions = app()->make(PermissionApplicator::class); + + return $permissions->checkUserHasEntityPermissionOnAny($action, $entityClass); +} + +/** + * Helper to access system settings. + * + * @return mixed|SettingService + */ +function setting(?string $key = null, mixed $default = null): mixed +{ + $settingService = app()->make(SettingService::class); + + if (is_null($key)) { + return $settingService; + } + + return $settingService->get($key, $default); +} + +/** + * Get a path to a theme resource. + * Returns null if a theme is not configured, and therefore a full path is not available for use. + */ +function theme_path(string $path = ''): ?string +{ + $theme = Theme::getTheme(); + if (!$theme) { + return null; + } + + return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path)); +} diff --git a/app/Attachment.php b/app/Attachment.php deleted file mode 100644 index fe291bec25d..00000000000 --- a/app/Attachment.php +++ /dev/null @@ -1,36 +0,0 @@ -name, '.')) return $this->name; - return $this->name . '.' . $this->extension; - } - - /** - * Get the page this file was uploaded to. - * @return Page - */ - public function page() - { - return $this->belongsTo(Page::class, 'uploaded_to'); - } - - /** - * Get the url of this file. - * @return string - */ - public function getUrl() - { - return baseUrl('/attachments/' . $this->id); - } - -} diff --git a/app/Book.php b/app/Book.php deleted file mode 100644 index 3fb87b4c519..00000000000 --- a/app/Book.php +++ /dev/null @@ -1,95 +0,0 @@ -slug) . '/' . trim($path, '/')); - } - return baseUrl('/books/' . urlencode($this->slug)); - } - - /** - * Returns book cover image, if book cover not exists return default cover image. - * @param int $width - Width of the image - * @param int $height - Height of the image - * @return string - */ - public function getBookCover($width = 440, $height = 250) - { - $default = baseUrl('/book_default_cover.png'); - if (!$this->image_id) return $default; - - try { - $cover = $this->cover ? baseUrl($this->cover->getThumb($width, $height, false)) : $default; - } catch (\Exception $err) { - $cover = $default; - } - return $cover; - } - - /** - * Get the cover image of the book - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function cover() - { - return $this->belongsTo(Image::class, 'image_id'); - } - /* - * Get the edit url for this book. - * @return string - */ - public function getEditUrl() - { - return $this->getUrl() . '/edit'; - } - - /** - * Get all pages within this book. - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function pages() - { - return $this->hasMany(Page::class); - } - - /** - * Get all chapters within this book. - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function chapters() - { - return $this->hasMany(Chapter::class); - } - - /** - * Get an excerpt of this book's description to the specified length or less. - * @param int $length - * @return string - */ - public function getExcerpt($length = 100) - { - $description = $this->description; - return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description; - } - - /** - * Return a generalised, common raw query that can be 'unioned' across entities. - * @return string - */ - public function entityRawQuery() - { - return "'BookStack\\\\Book' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at"; - } - -} diff --git a/app/Chapter.php b/app/Chapter.php deleted file mode 100644 index b08cb913a42..00000000000 --- a/app/Chapter.php +++ /dev/null @@ -1,63 +0,0 @@ -belongsTo(Book::class); - } - - /** - * Get the pages that this chapter contains. - * @param string $dir - * @return mixed - */ - public function pages($dir = 'ASC') - { - return $this->hasMany(Page::class)->orderBy('priority', $dir); - } - - /** - * Get the url of this chapter. - * @param string|bool $path - * @return string - */ - public function getUrl($path = false) - { - $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug; - if ($path !== false) { - return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug) . '/' . trim($path, '/')); - } - return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug)); - } - - /** - * Get an excerpt of this chapter's description to the specified length or less. - * @param int $length - * @return string - */ - public function getExcerpt($length = 100) - { - $description = $this->description; - return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description; - } - - /** - * Return a generalised, common raw query that can be 'unioned' across entities. - * @return string - */ - public function entityRawQuery() - { - return "'BookStack\\\\Chapter' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, '' as html, book_id, priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at"; - } - -} diff --git a/app/Comment.php b/app/Comment.php deleted file mode 100644 index 2800ab21ad3..00000000000 --- a/app/Comment.php +++ /dev/null @@ -1,43 +0,0 @@ -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(); - } -} diff --git a/app/Config/api.php b/app/Config/api.php new file mode 100644 index 00000000000..03f191fee3b --- /dev/null +++ b/app/Config/api.php @@ -0,0 +1,23 @@ + env('API_DEFAULT_ITEM_COUNT', 100), + + // The maximum number of items that can be returned in a listing API request. + 'max_item_count' => env('API_MAX_ITEM_COUNT', 500), + + // The number of API requests that can be made per minute by a single user. + 'requests_per_minute' => env('API_REQUESTS_PER_MIN', 180), + +]; diff --git a/app/Config/app.php b/app/Config/app.php new file mode 100644 index 00000000000..c38cd0e1f43 --- /dev/null +++ b/app/Config/app.php @@ -0,0 +1,164 @@ + env('APP_ENV', 'production'), + + // Enter the application in debug mode. + // Shows much more verbose error messages. Has potential to show + // private configuration variables so should remain disabled in public. + 'debug' => env('APP_DEBUG', false), + + // The number of revisions to keep in the database. + // Once this limit is reached older revisions will be deleted. + // If set to false then a limit will not be enforced. + 'revision_limit' => env('REVISION_LIMIT', 100), + + // The number of days that content will remain in the recycle bin before + // being considered for auto-removal. It is not a guarantee that content will + // be removed after this time. + // Set to 0 for no recycle bin functionality. + // Set to -1 for unlimited recycle bin lifetime. + 'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30), + + // The limit for all uploaded files, including images and attachments in MB. + 'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50), + + // Control the behaviour of content filtering, primarily used for page content. + // This setting is a string of characters which represent different available filters: + // - j - Filter out JavaScript and unknown binary data based content + // - h - Filter out unexpected, and potentially dangerous, HTML elements + // - f - Filter out unexpected form elements + // - a - Run content through a more complex allowlist filter + // This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used. + // Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures. + 'content_filtering' => env('APP_CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jhfa'), + + // Allow server-side fetches to be performed to potentially unknown + // and user-provided locations. Primarily used in exports when loading + // in externally referenced assets. + 'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false), + + // Override the default behaviour for allowing crawlers to crawl the instance. + // May be ignored if the underlying view has been overridden or modified. + // Defaults to null in which case the 'app-public' status is used instead. + 'allow_robots' => env('ALLOW_ROBOTS', null), + + // Application Base URL, Used by laravel in development commands + // and used by BookStack in URL generation. + 'url' => env('APP_URL', '') === 'http://bookstack.dev' ? '' : env('APP_URL', ''), + + // A list of hosts that BookStack can be iframed within. + // Space separated if multiple. BookStack host domain is auto-inferred. + 'iframe_hosts' => env('ALLOWED_IFRAME_HOSTS', null), + + // A list of sources/hostnames that can be loaded within iframes within BookStack. + // Space separated if multiple. BookStack host domain is auto-inferred. + // Can be set to a lone "*" to allow all sources for iframe content (Not advised). + // Defaults to a set of common services. + // Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured. + 'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'), + + // A list of style sources/hostnames that can be loaded styles within BookStack. + // Space separated if multiple. BookStack host domain is auto-inferred. + // If not set, a permissive default set is used to reduce potential breakage. + 'style_sources' => env('ALLOWED_STYLE_SOURCES', null), + + // A list of sources/hostnames that can be loaded as image content within BookStack. + // Space separated if multiple. BookStack host domain is auto-inferred, in addition to + // data and blob images, due to their use for various functionality. + // If not set, a permissive default set is used to reduce potential breakage. + 'image_sources' => env('ALLOWED_IMAGE_SOURCES', null), + + // 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 separated if multiple. Can use '*' as a wildcard. + // Values will be compared prefix-matched, case-insensitive, against called SSR urls. + // Defaults to allow all hosts. + 'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'), + + // Alter the precision of IP addresses stored by BookStack. + // Integer value between 0 (IP hidden) to 4 (Full IP usage) + 'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4), + + // Application timezone for stored date/time values. + 'timezone' => env('APP_TIMEZONE', 'UTC'), + // Application timezone for displayed date/time values in the UI. + 'display_timezone' => env('APP_DISPLAY_TIMEZONE', env('APP_TIMEZONE', 'UTC')), + + // Default locale to use + // A default variant is also stored since Laravel can overwrite + // app.locale when dynamically setting the locale in-app. + 'locale' => env('APP_LANG', 'en'), + 'default_locale' => env('APP_LANG', 'en'), + + // Application Fallback Locale + 'fallback_locale' => 'en', + + // Faker Locale + 'faker_locale' => 'en_GB', + + // Auto-detect the locale for public users + // For public users their locale can be guessed by headers sent by their + // browser. This is usually set by users in their browser settings. + // If not found the default app locale will be used. + 'auto_detect_locale' => env('APP_AUTO_LANG_PUBLIC', true), + + // Encryption key + 'key' => env('APP_KEY', 'AbAZchsay4uBTU33RubBzLKw203yqSqr'), + + // Encryption cipher + 'cipher' => 'AES-256-CBC', + + // Maintenance Mode Driver + 'maintenance' => [ + 'driver' => 'file', + // 'store' => 'redis', + ], + + // Application Service Providers + 'providers' => ServiceProvider::defaultProviders()->merge([ + // Third party service providers + SocialiteProviders\Manager\ServiceProvider::class, + + // BookStack custom service providers + BookStack\App\Providers\ThemeServiceProvider::class, + BookStack\App\Providers\AppServiceProvider::class, + BookStack\App\Providers\AuthServiceProvider::class, + BookStack\App\Providers\EventServiceProvider::class, + BookStack\App\Providers\RouteServiceProvider::class, + BookStack\App\Providers\TranslationServiceProvider::class, + BookStack\App\Providers\ValidationRuleServiceProvider::class, + BookStack\App\Providers\ViewTweaksServiceProvider::class, + ])->toArray(), + + // Class Aliases + // This array of class aliases to be registered on application start. + 'aliases' => Facade::defaultAliases()->merge([ + // Laravel Packages + 'Socialite' => Laravel\Socialite\Facades\Socialite::class, + + // Custom BookStack + 'Activity' => BookStack\Facades\Activity::class, + 'Theme' => BookStack\Facades\Theme::class, + ])->toArray(), + + // Proxy configuration + 'proxies' => env('APP_PROXIES', ''), + +]; diff --git a/app/Config/auth.php b/app/Config/auth.php new file mode 100644 index 00000000000..b1578fdb708 --- /dev/null +++ b/app/Config/auth.php @@ -0,0 +1,96 @@ + env('AUTH_METHOD', 'standard'), + + // Automatically initiate login via external auth system if it's the sole auth method. + // Works with saml2 or oidc auth methods. + 'auto_initiate' => env('AUTH_AUTO_INITIATE', false), + + // Authentication Defaults + // This option controls the default authentication "guard" and password + // reset options for your application. + 'defaults' => [ + 'guard' => env('AUTH_METHOD', 'standard'), + 'passwords' => 'users', + ], + + // Authentication Guards + // All authentication drivers have a user provider. This defines how the + // users are actually retrieved out of your database or other storage + // mechanisms used by this application to persist your user's data. + // Supported drivers: "session", "api-token", "ldap-session", "async-external-session" + 'guards' => [ + 'standard' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + 'ldap' => [ + 'driver' => 'ldap-session', + 'provider' => 'external', + ], + 'saml2' => [ + 'driver' => 'async-external-session', + 'provider' => 'external', + ], + 'oidc' => [ + 'driver' => 'async-external-session', + 'provider' => 'external', + ], + 'api' => [ + 'driver' => 'api-token', + ], + ], + + // User Providers + // All authentication drivers have a user provider. This defines how the + // users are actually retrieved out of your database or other storage + // mechanisms used by this application to persist your user's data. + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => \BookStack\Users\Models\User::class, + ], + + 'external' => [ + 'driver' => 'external-users', + 'model' => \BookStack\Users\Models\User::class, + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + // Resetting Passwords + // The expire time is the number of minutes that the reset token should be + // considered valid. This security feature keeps tokens short-lived so + // they have less time to be guessed. You may change this as needed. + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'email' => 'emails.password', + 'table' => 'password_resets', + 'expire' => 60, + 'throttle' => 60, + ], + ], + + // Password Confirmation Timeout + // Here you may define the amount of seconds before a password confirmation + // times out and the user is prompted to re-enter their password via the + // confirmation screen. By default, the timeout lasts for three hours. + 'password_timeout' => 10800, + +]; diff --git a/app/Config/cache.php b/app/Config/cache.php new file mode 100644 index 00000000000..01c822a653b --- /dev/null +++ b/app/Config/cache.php @@ -0,0 +1,90 @@ + $memcachedServer) { + $memcachedServerDetails = explode(':', $memcachedServer); + if (count($memcachedServerDetails) < 2) { + $memcachedServerDetails[] = '11211'; + } + if (count($memcachedServerDetails) < 3) { + $memcachedServerDetails[] = '100'; + } + $memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails); + } +} + +return [ + + // Default cache store to use + // Can be overridden at cache call-time + 'default' => env('CACHE_DRIVER', 'file'), + + // Available caches stores + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'table' => 'cache', + 'connection' => null, + 'lock_connection' => null, + 'lock_table' => null, + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache'), + 'lock_path' => storage_path('framework/cache'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => $memcachedServers ?? [], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + 'lock_connection' => 'default', + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing a RAM based store such as APC or Memcached, there might + | be other applications utilizing the same cache. So, we'll specify a + | value to get prefixed to all our keys so we can avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', 'bookstack_cache_'), + +]; diff --git a/app/Config/clockwork.php b/app/Config/clockwork.php new file mode 100644 index 00000000000..bd59eaf71d8 --- /dev/null +++ b/app/Config/clockwork.php @@ -0,0 +1,417 @@ + env('CLOCKWORK_ENABLE', false), + + /* + |------------------------------------------------------------------------------------------------------------------ + | Features + |------------------------------------------------------------------------------------------------------------------ + | + | You can enable or disable various Clockwork features here. Some features have additional settings (eg. slow query + | threshold for database queries). + | + */ + + 'features' => [ + + // Cache usage stats and cache queries including results + 'cache' => [ + 'enabled' => true, + + // Collect cache queries + 'collect_queries' => true, + + // Collect values from cache queries (high performance impact with a very high number of queries) + 'collect_values' => false, + ], + + // Database usage stats and queries + 'database' => [ + 'enabled' => true, + + // Collect database queries (high performance impact with a very high number of queries) + 'collect_queries' => true, + + // Collect details of models updates (high performance impact with a lot of model updates) + 'collect_models_actions' => true, + + // Collect details of retrieved models (very high performance impact with a lot of models retrieved) + 'collect_models_retrieved' => false, + + // Query execution time threshold in miliseconds after which the query will be marked as slow + 'slow_threshold' => null, + + // Collect only slow database queries + 'slow_only' => false, + + // Detect and report duplicate (N+1) queries + 'detect_duplicate_queries' => false, + ], + + // Dispatched events + 'events' => [ + 'enabled' => true, + + // Ignored events (framework events are ignored by default) + 'ignored_events' => [ + // App\Events\UserRegistered::class, + // 'user.registered' + ], + ], + + // Laravel log (you can still log directly to Clockwork with laravel log disabled) + 'log' => [ + 'enabled' => true, + ], + + // Sent notifications + 'notifications' => [ + 'enabled' => true, + ], + + // Performance metrics + 'performance' => [ + // Allow collecting of client metrics. Requires separate clockwork-browser npm package. + 'client_metrics' => true, + ], + + // Dispatched queue jobs + 'queue' => [ + 'enabled' => true, + ], + + // Redis commands + 'redis' => [ + 'enabled' => true, + ], + + // Routes list + 'routes' => [ + 'enabled' => false, + + // Collect only routes from particular namespaces (only application routes by default) + 'only_namespaces' => ['App'], + ], + + // Rendered views + 'views' => [ + 'enabled' => true, + + // Collect views including view data (high performance impact with a high number of views) + 'collect_data' => false, + + // Use Twig profiler instead of Laravel events for apps using laravel-twigbridge (more precise, but does + // not support collecting view data) + 'use_twig_profiler' => false, + ], + + ], + + /* + |------------------------------------------------------------------------------------------------------------------ + | Enable web UI + |------------------------------------------------------------------------------------------------------------------ + | + | Clockwork comes with a web UI accessibla via http://your.app/clockwork. Here you can enable or disable this + | feature. You can also set a custom path for the web UI. + | + */ + + 'web' => true, + + /* + |------------------------------------------------------------------------------------------------------------------ + | Enable toolbar + |------------------------------------------------------------------------------------------------------------------ + | + | Clockwork can show a toolbar with basic metrics on all responses. Here you can enable or disable this feature. + | Requires a separate clockwork-browser npm library. + | For installation instructions see https://underground.works/clockwork/#docs-viewing-data + | + */ + + 'toolbar' => true, + + /* + |------------------------------------------------------------------------------------------------------------------ + | HTTP requests collection + |------------------------------------------------------------------------------------------------------------------ + | + | Clockwork collects data about HTTP requests to your app. Here you can choose which requests should be collected. + | + */ + + 'requests' => [ + // With on-demand mode enabled, Clockwork will only profile requests when the browser extension is open or you + // manually pass a "clockwork-profile" cookie or get/post data key. + // Optionally you can specify a "secret" that has to be passed as the value to enable profiling. + 'on_demand' => false, + + // Collect only errors (requests with HTTP 4xx and 5xx responses) + 'errors_only' => false, + + // Response time threshold in miliseconds after which the request will be marked as slow + 'slow_threshold' => null, + + // Collect only slow requests + 'slow_only' => false, + + // Sample the collected requests (eg. set to 100 to collect only 1 in 100 requests) + 'sample' => false, + + // List of URIs that should not be collected + 'except' => [ + '/uploads/images/.*', // BookStack image requests + + '/horizon/.*', // Laravel Horizon requests + '/telescope/.*', // Laravel Telescope requests + '/_debugbar/.*', // Laravel DebugBar requests + ], + + // List of URIs that should be collected, any other URI will not be collected if not empty + 'only' => [ + // '/api/.*' + ], + + // Don't collect OPTIONS requests, mostly used in the CSRF pre-flight requests and are rarely of interest + 'except_preflight' => true, + ], + + /* + |------------------------------------------------------------------------------------------------------------------ + | Artisan commands collection + |------------------------------------------------------------------------------------------------------------------ + | + | Clockwork can collect data about executed artisan commands. Here you can enable and configure which commands + | should be collected. + | + */ + + 'artisan' => [ + // Enable or disable collection of executed Artisan commands + 'collect' => false, + + // List of commands that should not be collected (built-in commands are not collected by default) + 'except' => [ + // 'inspire' + ], + + // List of commands that should be collected, any other command will not be collected if not empty + 'only' => [ + // 'inspire' + ], + + // Enable or disable collection of command output + 'collect_output' => false, + + // Enable or disable collection of built-in Laravel commands + 'except_laravel_commands' => true, + ], + + /* + |------------------------------------------------------------------------------------------------------------------ + | Queue jobs collection + |------------------------------------------------------------------------------------------------------------------ + | + | Clockwork can collect data about executed queue jobs. Here you can enable and configure which queue jobs should + | be collected. + | + */ + + 'queue' => [ + // Enable or disable collection of executed queue jobs + 'collect' => false, + + // List of queue jobs that should not be collected + 'except' => [ + // App\Jobs\ExpensiveJob::class + ], + + // List of queue jobs that should be collected, any other queue job will not be collected if not empty + 'only' => [ + // App\Jobs\BuggyJob::class + ], + ], + + /* + |------------------------------------------------------------------------------------------------------------------ + | Tests collection + |------------------------------------------------------------------------------------------------------------------ + | + | Clockwork can collect data about executed tests. Here you can enable and configure which tests should be + | collected. + | + */ + + 'tests' => [ + // Enable or disable collection of ran tests + 'collect' => false, + + // List of tests that should not be collected + 'except' => [ + // Tests\Unit\ExampleTest::class + ], + ], + + /* + |------------------------------------------------------------------------------------------------------------------ + | Enable data collection when Clockwork is disabled + |------------------------------------------------------------------------------------------------------------------ + | + | You can enable this setting to collect data even when Clockwork is disabled. Eg. for future analysis. + | + */ + + 'collect_data_always' => false, + + /* + |------------------------------------------------------------------------------------------------------------------ + | Metadata storage + |------------------------------------------------------------------------------------------------------------------ + | + | Configure how is the metadata collected by Clockwork stored. Two options are available: + | - files - A simple fast storage implementation storing data in one-per-request files. + | - sql - Stores requests in a sql database. Supports MySQL, Postgresql, Sqlite and requires PDO. + | + */ + + 'storage' => 'files', + + // Path where the Clockwork metadata is stored + 'storage_files_path' => storage_path('clockwork'), + + // Compress the metadata files using gzip, trading a little bit of performance for lower disk usage + 'storage_files_compress' => false, + + // SQL database to use, can be a name of database configured in database.php or a path to a sqlite file + 'storage_sql_database' => storage_path('clockwork.sqlite'), + + // SQL table name to use, the table is automatically created and udpated when needed + 'storage_sql_table' => 'clockwork', + + // Maximum lifetime of collected metadata in minutes, older requests will automatically be deleted, false to disable + 'storage_expiration' => 60 * 24 * 7, + + /* + |------------------------------------------------------------------------------------------------------------------ + | Authentication + |------------------------------------------------------------------------------------------------------------------ + | + | Clockwork can be configured to require authentication before allowing access to the collected data. This might be + | useful when the application is publicly accessible. Setting to true will enable a simple authentication with a + | pre-configured password. You can also pass a class name of a custom implementation. + | + */ + + 'authentication' => false, + + // Password for the simple authentication + 'authentication_password' => 'VerySecretPassword', + + /* + |------------------------------------------------------------------------------------------------------------------ + | Stack traces collection + |------------------------------------------------------------------------------------------------------------------ + | + | Clockwork can collect stack traces for log messages and certain data like database queries. Here you can set + | whether to collect stack traces, limit the number of collected frames and set further configuration. Collecting + | long stack traces considerably increases metadata size. + | + */ + + 'stack_traces' => [ + // Enable or disable collecting of stack traces + 'enabled' => true, + + // Limit the number of frames to be collected + 'limit' => 10, + + // List of vendor names to skip when determining caller, common vendors are automatically added + 'skip_vendors' => [ + // 'phpunit' + ], + + // List of namespaces to skip when determining caller + 'skip_namespaces' => [ + // 'Laravel' + ], + + // List of class names to skip when determining caller + 'skip_classes' => [ + // App\CustomLog::class + ], + + ], + + /* + |------------------------------------------------------------------------------------------------------------------ + | Serialization + |------------------------------------------------------------------------------------------------------------------ + | + | Clockwork serializes the collected data to json for storage and transfer. Here you can configure certain aspects + | of serialization. Serialization has a large effect on the cpu time and memory usage. + | + */ + + // Maximum depth of serialized multi-level arrays and objects + 'serialization_depth' => 10, + + // A list of classes that will never be serialized (eg. a common service container class) + 'serialization_blackbox' => [ + \Illuminate\Container\Container::class, + \Illuminate\Foundation\Application::class, + ], + + /* + |------------------------------------------------------------------------------------------------------------------ + | Register helpers + |------------------------------------------------------------------------------------------------------------------ + | + | Clockwork comes with a "clock" global helper function. You can use this helper to quickly log something and to + | access the Clockwork instance. + | + */ + + 'register_helpers' => true, + + /* + |------------------------------------------------------------------------------------------------------------------ + | Send Headers for AJAX request + |------------------------------------------------------------------------------------------------------------------ + | + | When trying to collect data the AJAX method can sometimes fail if it is missing required headers. For example, an + | API might require a version number using Accept headers to route the HTTP request to the correct codebase. + | + */ + + 'headers' => [ + // 'Accept' => 'application/vnd.com.whatever.v1+json', + ], + + /* + |------------------------------------------------------------------------------------------------------------------ + | Server-Timing + |------------------------------------------------------------------------------------------------------------------ + | + | Clockwork supports the W3C Server Timing specification, which allows for collecting a simple performance metrics + | in a cross-browser way. Eg. in Chrome, your app, database and timeline event timings will be shown in the Dev + | Tools network tab. This setting specifies the max number of timeline events that will be sent. Setting to false + | will disable the feature. + | + */ + + 'server_timing' => 10, + +]; diff --git a/app/Config/database.php b/app/Config/database.php new file mode 100644 index 00000000000..86bae5f5b63 --- /dev/null +++ b/app/Config/database.php @@ -0,0 +1,112 @@ + '127.0.0.1', 'port' => '6379', 'database' => '0', 'password' => null]; + $redisServers = explode(',', trim(env('REDIS_SERVERS', '127.0.0.1:6379:0'), ',')); + $redisConfig = ['client' => 'predis']; + $cluster = count($redisServers) > 1; + + if ($cluster) { + $redisConfig['clusters'] = ['default' => []]; + } + + foreach ($redisServers as $index => $redisServer) { + $redisServerDetails = explode(':', $redisServer); + + $serverConfig = []; + $configIndex = 0; + foreach ($redisDefaults as $configKey => $configDefault) { + $serverConfig[$configKey] = ($redisServerDetails[$configIndex] ?? $configDefault); + $configIndex++; + } + + if ($cluster) { + $redisConfig['clusters']['default'][] = $serverConfig; + } else { + $redisConfig['default'] = $serverConfig; + } + } +} + +// MYSQL +// Split out port from host if set +$mysqlHost = env('DB_HOST', 'localhost'); +$mysqlHostExploded = explode(':', $mysqlHost); +$mysqlPort = env('DB_PORT', 3306); +$mysqlHostIpv6 = str_starts_with($mysqlHost, '['); +if ($mysqlHostIpv6 && str_contains($mysqlHost, ']:')) { + $mysqlHost = implode(':', array_slice($mysqlHostExploded, 0, -1)); + $mysqlPort = intval(end($mysqlHostExploded)); +} else if (!$mysqlHostIpv6 && count($mysqlHostExploded) > 1) { + $mysqlHost = $mysqlHostExploded[0]; + $mysqlPort = intval($mysqlHostExploded[1]); +} + +return [ + + // Default database connection name. + // Options: mysql, mysql_testing + 'default' => env('DB_CONNECTION', 'mysql'), + + // Available database connections + // Many of those shown here are unsupported by BookStack. + 'connections' => [ + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DATABASE_URL'), + 'host' => $mysqlHost, + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'port' => $mysqlPort, + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + // Prefixes are only semi-supported and may be unstable + // since they are not tested as part of our automated test suite. + // If used, the prefix should not be changed; otherwise you will likely receive errors. + 'prefix' => env('DB_TABLE_PREFIX', ''), + 'prefix_indexes' => true, + 'strict' => false, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mysql_testing' => [ + 'driver' => 'mysql', + 'url' => env('TEST_DATABASE_URL'), + 'host' => '127.0.0.1', + 'database' => 'bookstack-test', + 'username' => env('MYSQL_USER', 'bookstack-test'), + 'password' => env('MYSQL_PASSWORD', 'bookstack-test'), + 'port' => $mysqlPort, + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => false, + ], + + ], + + // Migration Repository Table + // This table keeps track of all the migrations that have already run for the application. + 'migrations' => 'migrations', + + // Redis configuration to use if set + 'redis' => $redisConfig ?? [], + +]; diff --git a/app/Config/debugbar.php b/app/Config/debugbar.php new file mode 100644 index 00000000000..53b5a087249 --- /dev/null +++ b/app/Config/debugbar.php @@ -0,0 +1,132 @@ + env('DEBUGBAR_ENABLED', false), + 'except' => [ + 'telescope*', + ], + + // DebugBar stores data for session/ajax requests. + // You can disable this, so the debugbar stores data in headers/session, + // but this can cause problems with large data collectors. + // By default, file storage (in the storage folder) is used. Redis and PDO + // can also be used. For PDO, run the package migrations first. + 'storage' => [ + 'enabled' => true, + 'driver' => 'file', // redis, file, pdo, custom + 'path' => storage_path('debugbar'), // For file driver + 'connection' => null, // Leave null for default connection (Redis/PDO) + 'provider' => '', // Instance of StorageInterface for custom driver + ], + + // Vendor files are included by default, but can be set to false. + // This can also be set to 'js' or 'css', to only include javascript or css vendor files. + // Vendor files are for css: font-awesome (including fonts) and highlight.js (css files) + // and for js: jquery and and highlight.js + // So if you want syntax highlighting, set it to true. + // jQuery is set to not conflict with existing jQuery scripts. + 'include_vendors' => true, + + // The Debugbar can capture Ajax requests and display them. If you don't want this (ie. because of errors), + // you can use this option to disable sending the data through the headers. + // Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools. + + 'capture_ajax' => true, + 'add_ajax_timing' => false, + + // When enabled, the Debugbar shows deprecated warnings for Symfony components + // in the Messages tab. + 'error_handler' => false, + + // The Debugbar can emulate the Clockwork headers, so you can use the Chrome + // Extension, without the server-side code. It uses Debugbar collectors instead. + 'clockwork' => false, + + // Enable/disable DataCollectors + 'collectors' => [ + 'phpinfo' => true, // Php version + 'messages' => true, // Messages + 'time' => true, // Time Datalogger + 'memory' => true, // Memory usage + 'exceptions' => true, // Exception displayer + 'log' => true, // Logs from Monolog (merged in messages if enabled) + 'db' => true, // Show database (PDO) queries and bindings + 'views' => true, // Views with their data + 'route' => true, // Current route information + 'auth' => true, // Display Laravel authentication status + 'gate' => true, // Display Laravel Gate checks + 'session' => true, // Display session data + 'symfony_request' => true, // Only one can be enabled.. + 'mail' => true, // Catch mail messages + 'laravel' => false, // Laravel version and environment + 'events' => false, // All events fired + 'default_request' => false, // Regular or special Symfony request logger + 'logs' => false, // Add the latest log messages + 'files' => false, // Show the included files + 'config' => false, // Display config settings + 'cache' => false, // Display cache events + 'models' => true, // Display models + ], + + // Configure some DataCollectors + 'options' => [ + 'auth' => [ + 'show_name' => true, // Also show the users name/email in the debugbar + ], + 'db' => [ + 'with_params' => true, // Render SQL with the parameters substituted + 'backtrace' => true, // Use a backtrace to find the origin of the query in your files. + 'timeline' => false, // Add the queries to the timeline + 'explain' => [ // Show EXPLAIN output on queries + 'enabled' => false, + 'types' => ['SELECT'], // ['SELECT', 'INSERT', 'UPDATE', 'DELETE']; for MySQL 5.6.3+ + ], + 'hints' => true, // Show hints for common mistakes + ], + 'mail' => [ + 'full_log' => false, + ], + 'views' => [ + 'data' => false, //Note: Can slow down the application, because the data can be quite large.. + ], + 'route' => [ + 'label' => true, // show complete route on bar + ], + 'logs' => [ + 'file' => null, + ], + 'cache' => [ + 'values' => true, // collect cache values + ], + ], + + // Inject Debugbar into the response + // Usually, the debugbar is added just before , by listening to the + // Response after the App is done. If you disable this, you have to add them + // in your template yourself. See http://phpdebugbar.com/docs/rendering.html + 'inject' => true, + + // DebugBar route prefix + // Sometimes you want to set route prefix to be used by DebugBar to load + // its resources from. Usually the need comes from misconfigured web server or + // from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97 + 'route_prefix' => '_debugbar', + + // DebugBar route domain + // By default DebugBar route served from the same domain that request served. + // To override default domain, specify it as a non-empty value. + 'route_domain' => env('APP_URL', '') === 'http://bookstack.dev' ? '' : env('APP_URL', ''), +]; diff --git a/app/Config/exports.php b/app/Config/exports.php new file mode 100644 index 00000000000..f48fe0a67a3 --- /dev/null +++ b/app/Config/exports.php @@ -0,0 +1,307 @@ + 'A4', + 'letter' => 'Letter', +]; + +$dompdfPaperSizeMap = [ + 'a4' => 'a4', + 'letter' => 'letter', +]; + +$exportPageSize = env('EXPORT_PAGE_SIZE', 'a4'); + +return [ + + // 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}" + 'pdf_command' => env('EXPORT_PDF_COMMAND', false), + + // The amount of time allowed for PDF generation command to run + // before the process times out and is stopped. + 'pdf_command_timeout' => env('EXPORT_PDF_COMMAND_TIMEOUT', 15), + + // 2024-04: Snappy/WKHTMLtoPDF now considered deprecated in regard to BookStack support. + 'snappy' => [ + 'pdf_binary' => env('WKHTMLTOPDF', false), + 'options' => [ + 'print-media-type' => true, + 'outline' => true, + 'page-size' => $snappyPaperSizeMap[$exportPageSize] ?? 'A4', + ], + ], + + 'dompdf' => [ + /** + * The location of the DOMPDF font directory. + * + * The location of the directory where DOMPDF will store fonts and font metrics + * Note: This directory must exist and be writable by the webserver process. + * *Please note the trailing slash.* + * + * Notes regarding fonts: + * Additional .afm font metrics can be added by executing load_font.php from command line. + * + * Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must + * be embedded in the pdf file or the PDF may not display correctly. This can significantly + * increase file size unless font subsetting is enabled. Before embedding a font please + * review your rights under the font license. + * + * Any font specification in the source HTML is translated to the closest font available + * in the font directory. + * + * The pdf standard "Base 14 fonts" are: + * Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique, + * Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique, + * Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic, + * Symbol, ZapfDingbats. + */ + 'font_dir' => storage_path('fonts/dompdf'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782) + + /** + * The location of the DOMPDF font cache directory. + * + * This directory contains the cached font metrics for the fonts used by DOMPDF. + * This directory can be the same as DOMPDF_FONT_DIR + * + * Note: This directory must exist and be writable by the webserver process. + */ + 'font_cache' => storage_path('fonts/dompdf/cache'), + + /** + * The location of a temporary directory. + * + * The directory specified must be writeable by the webserver process. + * The temporary directory is required to download remote images and when + * using the PFDLib back end. + */ + 'temp_dir' => sys_get_temp_dir(), + + /** + * ==== IMPORTANT ====. + * + * dompdf's "chroot": Prevents dompdf from accessing system files or other + * files on the webserver. All local files opened by dompdf must be in a + * subdirectory of this directory. DO NOT set it to '/' since this could + * allow an attacker to use dompdf to read any files on the server. This + * should be an absolute path. + * This is only checked on command line call by dompdf.php, but not by + * direct class use like: + * $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output(); + */ + 'chroot' => realpath(public_path()), + + /** + * Protocol whitelist. + * + * Protocols and PHP wrappers allowed in URIs, and the validation rules + * that determine if a resouce may be loaded. Full support is not guaranteed + * for the protocols/wrappers specified + * by this array. + * + * @var array + */ + 'allowed_protocols' => [ + "data://" => ["rules" => []], + 'file://' => ['rules' => []], + 'http://' => ['rules' => []], + 'https://' => ['rules' => []], + ], + + /** + * @var string + */ + 'log_output_file' => null, + + /** + * Whether to enable font subsetting or not. + */ + 'enable_font_subsetting' => false, + + /** + * The PDF rendering backend to use. + * + * Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and + * 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will + * fall back on CPDF. 'GD' renders PDFs to graphic files. {@link * Canvas_Factory} ultimately determines which rendering class to instantiate + * based on this setting. + * + * Both PDFLib & CPDF rendering backends provide sufficient rendering + * capabilities for dompdf, however additional features (e.g. object, + * image and font support, etc.) differ between backends. Please see + * {@link PDFLib_Adapter} for more information on the PDFLib backend + * and {@link CPDF_Adapter} and lib/class.pdf.php for more information + * on CPDF. Also see the documentation for each backend at the links + * below. + * + * The GD rendering backend is a little different than PDFLib and + * CPDF. Several features of CPDF and PDFLib are not supported or do + * not make any sense when creating image files. For example, + * multiple pages are not supported, nor are PDF 'objects'. Have a + * look at {@link GD_Adapter} for more information. GD support is + * experimental, so use it at your own risk. + * + * @link http://www.pdflib.com + * @link http://www.ros.co.nz/pdf + * @link http://www.php.net/image + */ + 'pdf_backend' => 'CPDF', + + /** + * PDFlib license key. + * + * If you are using a licensed, commercial version of PDFlib, specify + * your license key here. If you are using PDFlib-Lite or are evaluating + * the commercial version of PDFlib, comment out this setting. + * + * @link http://www.pdflib.com + * + * If pdflib present in web server and auto or selected explicitely above, + * a real license code must exist! + */ + //"DOMPDF_PDFLIB_LICENSE" => "your license key here", + + /** + * html target media view which should be rendered into pdf. + * List of types and parsing rules for future extensions: + * http://www.w3.org/TR/REC-html40/types.html + * screen, tty, tv, projection, handheld, print, braille, aural, all + * Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3. + * Note, even though the generated pdf file is intended for print output, + * the desired content might be different (e.g. screen or projection view of html file). + * Therefore allow specification of content here. + */ + 'default_media_type' => 'print', + + /** + * The default paper size. + * + * North America standard is "letter"; other countries generally "a4" + * + * @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.) + */ + 'default_paper_size' => $dompdfPaperSizeMap[$exportPageSize] ?? 'a4', + + /** + * The default paper orientation. + * + * The orientation of the page (portrait or landscape). + * + * @var string + */ + 'default_paper_orientation' => 'portrait', + + /** + * The default font family. + * + * Used if no suitable fonts can be found. This must exist in the font folder. + * + * @var string + */ + 'default_font' => 'dejavu sans', + + /** + * Image DPI setting. + * + * This setting determines the default DPI setting for images and fonts. The + * DPI may be overridden for inline images by explictly setting the + * image's width & height style attributes (i.e. if the image's native + * width is 600 pixels and you specify the image's width as 72 points, + * the image will have a DPI of 600 in the rendered PDF. The DPI of + * background images can not be overridden and is controlled entirely + * via this parameter. + * + * For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI). + * If a size in html is given as px (or without unit as image size), + * this tells the corresponding size in pt. + * This adjusts the relative sizes to be similar to the rendering of the + * html page in a reference browser. + * + * In pdf, always 1 pt = 1/72 inch + * + * Rendering resolution of various browsers in px per inch: + * Windows Firefox and Internet Explorer: + * SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:? + * Linux Firefox: + * about:config *resolution: Default:96 + * (xorg screen dimension in mm and Desktop font dpi settings are ignored) + * + * Take care about extra font/image zoom factor of browser. + * + * In images, size in pixel attribute, img css style, are overriding + * the real image dimension in px for rendering. + * + * @var int + */ + 'dpi' => 96, + + /** + * Enable inline PHP. + * + * If this setting is set to true then DOMPDF will automatically evaluate + * inline PHP contained within tags. + * + * Enabling this for documents you do not trust (e.g. arbitrary remote html + * pages) is a security risk. Set this option to false if you wish to process + * untrusted documents. + * + * @var bool + */ + 'enable_php' => false, + + /** + * Enable inline Javascript. + * + * If this setting is set to true then DOMPDF will automatically insert + * JavaScript code contained within tags. + * + * @var bool + */ + 'enable_javascript' => false, + + /** + * Enable remote file access. + * + * If this setting is set to true, DOMPDF will access remote sites for + * images and CSS files as required. + * This is required for part of test case www/test/image_variants.html through www/examples.php + * + * Attention! + * This can be a security risk, in particular in combination with DOMPDF_ENABLE_PHP and + * allowing remote access to dompdf.php or on allowing remote html code to be passed to + * $dompdf = new DOMPDF(, $dompdf->load_html(..., + * This allows anonymous users to download legally doubtful internet content which on + * tracing back appears to being downloaded by your server, or allows malicious php code + * in remote html pages to be executed by your server with your account privileges. + * + * @var bool + */ + 'enable_remote' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false), + + /** + * A ratio applied to the fonts height to be more like browsers' line height. + */ + 'font_height_ratio' => 1.1, + + /** + * Use the HTML5 Lib parser. + * + * @deprecated This feature is now always on in dompdf 2.x + * + * @var bool + */ + 'enable_html5_parser' => true, + ], +]; diff --git a/app/Config/filesystems.php b/app/Config/filesystems.php new file mode 100644 index 00000000000..facf5f2df2f --- /dev/null +++ b/app/Config/filesystems.php @@ -0,0 +1,76 @@ + env('STORAGE_TYPE', 'local'), + + // Filesystem to use specifically for image uploads. + 'images' => env('STORAGE_IMAGE_TYPE', env('STORAGE_TYPE', 'local')), + + // Filesystem to use specifically for file attachments. + 'attachments' => env('STORAGE_ATTACHMENT_TYPE', env('STORAGE_TYPE', 'local')), + + // Storage URL + // This is the url to where the storage is located for when using an external + // file storage service, such as s3, to store publicly accessible assets. + 'url' => env('STORAGE_URL', false), + + // Available filesystem disks + // Only local, local_secure & s3 are supported by BookStack + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => public_path(), + 'serve' => false, + 'throw' => true, + 'directory_visibility' => 'public', + ], + + 'local_secure_attachments' => [ + 'driver' => 'local', + 'root' => storage_path('uploads/files/'), + 'serve' => false, + 'throw' => true, + ], + + 'local_secure_images' => [ + 'driver' => 'local', + 'root' => storage_path('uploads/images/'), + 'serve' => false, + 'throw' => true, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('STORAGE_S3_KEY', 'your-key'), + 'secret' => env('STORAGE_S3_SECRET', 'your-secret'), + 'region' => env('STORAGE_S3_REGION', 'your-region'), + 'bucket' => env('STORAGE_S3_BUCKET', 'your-bucket'), + 'endpoint' => env('STORAGE_S3_ENDPOINT', null), + 'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null, + 'throw' => true, + 'stream_reads' => false, + ], + + ], + + // Symbolic Links + // Here you may configure the symbolic links that will be created when the + // `storage:link` Artisan command is executed. The array keys should be + // the locations of the links and the values should be their targets. + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/app/Config/hashing.php b/app/Config/hashing.php new file mode 100644 index 00000000000..91d0db16b9e --- /dev/null +++ b/app/Config/hashing.php @@ -0,0 +1,38 @@ + 'bcrypt', + + // Bcrypt Options + // Here you may specify the configuration options that should be used when + // passwords are hashed using the Bcrypt algorithm. This will allow you + // to control the amount of time it takes to hash the given password. + 'bcrypt' => [ + 'rounds' => env('BCRYPT_ROUNDS', 12), + 'verify' => true, + ], + + // Argon Options + // Here you may specify the configuration options that should be used when + // passwords are hashed using the Argon algorithm. These will allow you + // to control the amount of time it takes to hash the given password. + 'argon' => [ + 'memory' => 1024, + 'threads' => 2, + 'time' => 2, + ], + +]; diff --git a/app/Config/logging.php b/app/Config/logging.php new file mode 100644 index 00000000000..f5cbd5ffc01 --- /dev/null +++ b/app/Config/logging.php @@ -0,0 +1,125 @@ + env('LOG_CHANNEL', 'single'), + + // Deprecations Log Channel + // This option controls the log channel that should be used to log warnings + // regarding deprecated PHP and library features. This allows you to get + // your application ready for upcoming major versions of dependencies. + 'deprecations' => [ + 'channel' => 'null', + 'trace' => false, + ], + + // Log Channels + // Here you may configure the log channels for your application. Out of + // the box, Laravel uses the Monolog PHP logging library. This gives + // you a variety of powerful log handlers / formatters to utilize. + // Available Drivers: "single", "daily", "slack", "syslog", + // "errorlog", "monolog", + // "custom", "stack" + 'channels' => [ + 'stack' => [ + 'driver' => 'stack', + 'channels' => ['daily'], + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => 'debug', + 'days' => 14, + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => 'debug', + 'days' => 7, + 'replace_placeholders' => true, + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => 'debug', + 'handler' => StreamHandler::class, + 'with' => [ + 'stream' => 'php://stderr', + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => 'debug', + 'facility' => LOG_USER, + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => 'debug', + 'replace_placeholders' => true, + ], + + // Custom errorlog implementation that logs out a plain, + // non-formatted message intended for the webserver log. + 'errorlog_plain_webserver' => [ + 'driver' => 'monolog', + 'level' => 'debug', + 'handler' => ErrorLogHandler::class, + 'handler_with' => [4], + 'formatter' => LineFormatter::class, + 'formatter_with' => [ + 'format' => '%message%', + ], + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + // Testing channel + // Uses a shared testing instance during tests + // so that logs can be checked against. + 'testing' => [ + 'driver' => 'testing', + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + ], + + // Failed Login Message + // Allows a configurable message to be logged when a login request fails. + 'failed_login' => [ + 'message' => env('LOG_FAILED_LOGIN_MESSAGE', null), + 'channel' => env('LOG_FAILED_LOGIN_CHANNEL', 'errorlog_plain_webserver'), + ], + +]; diff --git a/app/Config/mail.php b/app/Config/mail.php new file mode 100644 index 00000000000..7256ce8848e --- /dev/null +++ b/app/Config/mail.php @@ -0,0 +1,68 @@ + env('MAIL_DRIVER', 'smtp'), + + // Global "From" address & name + 'from' => [ + 'address' => env('MAIL_FROM', 'bookstack@example.com'), + 'name' => env('MAIL_FROM_NAME', 'BookStack'), + ], + + // Mailer Configurations + // Available mailing methods and their settings. + 'mailers' => [ + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => null, + 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), + 'port' => $mailPort, + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'verify_peer' => env('MAIL_VERIFY_SSL', true), + 'timeout' => null, + 'local_domain' => null, + 'require_tls' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl' || $mailPort === 465), + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + ], + ], +]; diff --git a/app/Config/oidc.php b/app/Config/oidc.php new file mode 100644 index 00000000000..16bec873c9c --- /dev/null +++ b/app/Config/oidc.php @@ -0,0 +1,63 @@ + env('OIDC_NAME', 'SSO'), + + // Dump user details after a login request for debugging purposes + 'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false), + + // Claim, within an OpenId token, to find the user's display name + 'display_name_claims' => env('OIDC_DISPLAY_NAME_CLAIMS', 'name'), + + // Claim, within an OpenID token, to use to connect a BookStack user to the OIDC user. + 'external_id_claim' => env('OIDC_EXTERNAL_ID_CLAIM', 'sub'), + + // OAuth2/OpenId client id, as configured in your Authorization server. + 'client_id' => env('OIDC_CLIENT_ID', null), + + // OAuth2/OpenId client secret, as configured in your Authorization server. + 'client_secret' => env('OIDC_CLIENT_SECRET', null), + + // The issuer of the identity token (id_token) this will be compared with + // what is returned in the token. + 'issuer' => env('OIDC_ISSUER', null), + + // Auto-discover the relevant endpoints and keys from the issuer. + // Fetched details are cached for 15 minutes. + 'discover' => env('OIDC_ISSUER_DISCOVER', false), + + // Public key that's used to verify the JWT token with. + // Can be the key value itself or a local 'file://public.key' reference. + 'jwt_public_key' => env('OIDC_PUBLIC_KEY', null), + + // OAuth2 endpoints. + 'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null), + 'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null), + 'userinfo_endpoint' => env('OIDC_USERINFO_ENDPOINT', null), + + // OIDC RP-Initiated Logout endpoint URL. + // A false value force-disables RP-Initiated Logout. + // A true value gets the URL from discovery, if active. + // A string value is used as the URL. + 'end_session_endpoint' => env('OIDC_END_SESSION_ENDPOINT', false), + + // Add extra scopes, upon those required, to the OIDC authentication request + // Multiple values can be provided comma seperated. + 'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null), + + // Enable fetching of the user's avatar from the 'picture' claim on login. + // Will only be fetched if the user doesn't already have an avatar image assigned. + // This can be a security risk due to performing server-side fetching (with up to 3 redirects) of + // data from external URLs. Only enable if you trust the OIDC auth provider to provide safe URLs for user images. + 'fetch_avatar' => env('OIDC_FETCH_AVATAR', false), + + // Group sync options + // Enable syncing, upon login, of OIDC groups to BookStack roles + 'user_to_groups' => env('OIDC_USER_TO_GROUPS', false), + // Attribute, within a OIDC ID token, to find group names within + 'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'), + // When syncing groups, remove any groups that no longer match. Otherwise, sync only adds new groups. + 'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false), +]; diff --git a/app/Config/queue.php b/app/Config/queue.php new file mode 100644 index 00000000000..08f3a5baab5 --- /dev/null +++ b/app/Config/queue.php @@ -0,0 +1,57 @@ + env('QUEUE_CONNECTION', 'sync'), + + // Queue connection configuration + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => null, + 'table' => 'jobs', + 'queue' => 'default', + 'retry_after' => 90, + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => 90, + 'block_for' => null, + 'after_commit' => false, + ], + + ], + + // Job batching + 'batching' => [ + 'database' => 'mysql', + 'table' => 'job_batches', + ], + + // Failed queue job logging + 'failed' => [ + 'driver' => 'database-uuids', + 'database' => 'mysql', + 'table' => 'failed_jobs', + ], + +]; diff --git a/app/Config/saml2.php b/app/Config/saml2.php new file mode 100644 index 00000000000..44d06c5b2e6 --- /dev/null +++ b/app/Config/saml2.php @@ -0,0 +1,160 @@ + env('SAML2_NAME', 'SSO'), + + // Dump user details after a login request for debugging purposes + 'dump_user_details' => env('SAML2_DUMP_USER_DETAILS', false), + + // Attribute, within a SAML response, to find the user's email address + 'email_attribute' => env('SAML2_EMAIL_ATTRIBUTE', 'email'), + // Attribute, within a SAML response, to find the user's display name + 'display_name_attributes' => explode('|', env('SAML2_DISPLAY_NAME_ATTRIBUTES', 'username')), + // Attribute, within a SAML response, to use to connect a BookStack user to the SAML user. + 'external_id_attribute' => env('SAML2_EXTERNAL_ID_ATTRIBUTE', null), + + // Group sync options + // Enable syncing, upon login, of SAML2 groups to BookStack groups + 'user_to_groups' => env('SAML2_USER_TO_GROUPS', false), + // Attribute, within a SAML response, to find group names on + 'group_attribute' => env('SAML2_GROUP_ATTRIBUTE', 'group'), + // When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups. + 'remove_from_groups' => env('SAML2_REMOVE_FROM_GROUPS', false), + + // Autoload IDP details from the metadata endpoint + 'autoload_from_metadata' => env('SAML2_AUTOLOAD_METADATA', false), + + // Overrides, in JSON format, to the configuration passed to underlying onelogin library. + 'onelogin_overrides' => env('SAML2_ONELOGIN_OVERRIDES', null), + + 'onelogin' => [ + // If 'strict' is True, then the PHP Toolkit will reject unsigned + // or unencrypted messages if it expects them signed or encrypted + // Also will reject the messages if not strictly follow the SAML + // standard: Destination, NameId, Conditions ... are validated too. + 'strict' => true, + + // Enable debug mode (to print errors) + 'debug' => env('APP_DEBUG', false), + + // Set a BaseURL to be used instead of try to guess + // the BaseURL of the view that process the SAML Message. + // Ex. http://sp.example.com/ + // http://example.com/sp/ + 'baseurl' => null, + + // Service Provider Data that we are deploying + 'sp' => [ + // Identifier of the SP entity (must be a URI) + 'entityId' => '', + + // Specifies info about where and how the message MUST be + // returned to the requester, in this case our SP. + 'assertionConsumerService' => [ + // URL Location where the from the IdP will be returned + 'url' => '', + // SAML protocol binding to be used when returning the + // message. Onelogin Toolkit supports for this endpoint the + // HTTP-POST binding only + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + ], + + // Specifies info about where and how the message MUST be + // returned to the requester, in this case our SP. + 'singleLogoutService' => [ + // URL Location where the from the IdP will be returned + 'url' => '', + // SAML protocol binding to be used when returning the + // message. Onelogin Toolkit supports for this endpoint the + // HTTP-Redirect binding only + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + ], + + // Specifies constraints on the name identifier to be used to + // represent the requested subject. + // Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported + 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + + // Usually x509cert and privateKey of the SP are provided by files placed at + // the certs folder. But we can also provide them with the following parameters + 'x509cert' => $SAML2_SP_x509 ?: '', + 'privateKey' => env('SAML2_SP_x509_KEY', ''), + ], + // Identity Provider Data that we want connect with our SP + 'idp' => [ + // Identifier of the IdP entity (must be a URI) + 'entityId' => env('SAML2_IDP_ENTITYID', null), + // SSO endpoint info of the IdP. (Authentication Request protocol) + 'singleSignOnService' => [ + // URL Target of the IdP where the SP will send the Authentication Request Message + 'url' => env('SAML2_IDP_SSO', null), + // SAML protocol binding to be used when returning the + // message. Onelogin Toolkit supports for this endpoint the + // HTTP-Redirect binding only + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + ], + // SLO endpoint info of the IdP. + 'singleLogoutService' => [ + // URL Location of the IdP where the SP will send the SLO Request + 'url' => env('SAML2_IDP_SLO', null), + // URL location of the IdP where the SP will send the SLO Response (ResponseLocation) + // if not set, url for the SLO Request will be used + 'responseUrl' => null, + // SAML protocol binding to be used when returning the + // message. Onelogin Toolkit supports for this endpoint the + // HTTP-Redirect binding only + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + ], + // Public x509 certificate of the IdP + 'x509cert' => env('SAML2_IDP_x509', null), + /* + * Instead of use the whole x509cert you can use a fingerprint in + * order to validate the SAMLResponse, but we don't recommend to use + * that method on production since is exploitable by a collision + * attack. + * (openssl x509 -noout -fingerprint -in "idp.crt" to generate it, + * or add for example the -sha256 , -sha384 or -sha512 parameter) + * + * If a fingerprint is provided, then the certFingerprintAlgorithm is required in order to + * let the toolkit know which Algorithm was used. Possible values: sha1, sha256, sha384 or sha512 + * 'sha1' is the default value. + */ + // 'certFingerprint' => '', + // 'certFingerprintAlgorithm' => 'sha1', + /* In some scenarios the IdP uses different certificates for + * signing/encryption, or is under key rollover phase and more + * than one certificate is published on IdP metadata. + * In order to handle that the toolkit offers that parameter. + * (when used, 'x509cert' and 'certFingerprint' values are + * ignored). + */ + // 'x509certMulti' => array( + // 'signing' => array( + // 0 => '', + // ), + // 'encryption' => array( + // 0 => '', + // ) + // ), + ], + 'security' => [ + // SAML2 Authn context + // When set to false no AuthContext will be sent in the AuthNRequest, + // When set to true (Default) you will get an AuthContext 'exact' 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'. + // Multiple forced values can be passed via a space separated array, For example: + // SAML2_IDP_AUTHNCONTEXT="urn:federation:authentication:windows urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" + 'requestedAuthnContext' => is_string($SAML2_IDP_AUTHNCONTEXT) ? explode(' ', $SAML2_IDP_AUTHNCONTEXT) : $SAML2_IDP_AUTHNCONTEXT, + // Sign requests and responses if a certificate is in use + 'logoutRequestSigned' => (bool) $SAML2_SP_x509, + 'logoutResponseSigned' => (bool) $SAML2_SP_x509, + 'authnRequestsSigned' => (bool) $SAML2_SP_x509, + 'lowercaseUrlencoding' => false, + ], + ], + +]; diff --git a/app/Config/services.php b/app/Config/services.php new file mode 100644 index 00000000000..d7345823150 --- /dev/null +++ b/app/Config/services.php @@ -0,0 +1,141 @@ + env('DISABLE_EXTERNAL_SERVICES', false), + + // Draw.io integration active + 'drawio' => env('DRAWIO', !env('DISABLE_EXTERNAL_SERVICES', false)), + + // URL for fetching avatars + 'avatar_url' => env('AVATAR_URL', ''), + + // Callback URL for social authentication methods + 'callback_url' => env('APP_URL', false), + + 'github' => [ + 'client_id' => env('GITHUB_APP_ID', false), + 'client_secret' => env('GITHUB_APP_SECRET', false), + 'redirect' => env('APP_URL') . '/login/service/github/callback', + 'name' => 'GitHub', + 'auto_register' => env('GITHUB_AUTO_REGISTER', false), + 'auto_confirm' => env('GITHUB_AUTO_CONFIRM_EMAIL', false), + ], + + 'google' => [ + 'client_id' => env('GOOGLE_APP_ID', false), + 'client_secret' => env('GOOGLE_APP_SECRET', false), + 'redirect' => env('APP_URL') . '/login/service/google/callback', + 'name' => 'Google', + 'auto_register' => env('GOOGLE_AUTO_REGISTER', false), + 'auto_confirm' => env('GOOGLE_AUTO_CONFIRM_EMAIL', false), + 'select_account' => env('GOOGLE_SELECT_ACCOUNT', false), + ], + + 'slack' => [ + 'client_id' => env('SLACK_APP_ID', false), + 'client_secret' => env('SLACK_APP_SECRET', false), + 'redirect' => env('APP_URL') . '/login/service/slack/callback', + 'name' => 'Slack', + 'auto_register' => env('SLACK_AUTO_REGISTER', false), + 'auto_confirm' => env('SLACK_AUTO_CONFIRM_EMAIL', false), + ], + + 'facebook' => [ + 'client_id' => env('FACEBOOK_APP_ID', false), + 'client_secret' => env('FACEBOOK_APP_SECRET', false), + 'redirect' => env('APP_URL') . '/login/service/facebook/callback', + 'name' => 'Facebook', + 'auto_register' => env('FACEBOOK_AUTO_REGISTER', false), + 'auto_confirm' => env('FACEBOOK_AUTO_CONFIRM_EMAIL', false), + ], + + 'twitter' => [ + 'client_id' => env('TWITTER_APP_ID', false), + 'client_secret' => env('TWITTER_APP_SECRET', false), + 'redirect' => env('APP_URL') . '/login/service/twitter/callback', + 'name' => 'Twitter', + 'auto_register' => env('TWITTER_AUTO_REGISTER', false), + 'auto_confirm' => env('TWITTER_AUTO_CONFIRM_EMAIL', false), + ], + + 'azure' => [ + 'client_id' => env('AZURE_APP_ID', false), + 'client_secret' => env('AZURE_APP_SECRET', false), + 'tenant' => env('AZURE_TENANT', false), + 'redirect' => env('APP_URL') . '/login/service/azure/callback', + 'name' => 'Microsoft Azure', + 'auto_register' => env('AZURE_AUTO_REGISTER', false), + 'auto_confirm' => env('AZURE_AUTO_CONFIRM_EMAIL', false), + ], + + 'okta' => [ + 'client_id' => env('OKTA_APP_ID'), + 'client_secret' => env('OKTA_APP_SECRET'), + 'redirect' => env('APP_URL') . '/login/service/okta/callback', + 'base_url' => env('OKTA_BASE_URL'), + 'name' => 'Okta', + 'auto_register' => env('OKTA_AUTO_REGISTER', false), + 'auto_confirm' => env('OKTA_AUTO_CONFIRM_EMAIL', false), + ], + + 'gitlab' => [ + 'client_id' => env('GITLAB_APP_ID'), + 'client_secret' => env('GITLAB_APP_SECRET'), + 'redirect' => env('APP_URL') . '/login/service/gitlab/callback', + 'instance_uri' => env('GITLAB_BASE_URI'), // Needed only for self hosted instances + 'name' => 'GitLab', + 'auto_register' => env('GITLAB_AUTO_REGISTER', false), + 'auto_confirm' => env('GITLAB_AUTO_CONFIRM_EMAIL', false), + ], + + 'twitch' => [ + 'client_id' => env('TWITCH_APP_ID'), + 'client_secret' => env('TWITCH_APP_SECRET'), + 'redirect' => env('APP_URL') . '/login/service/twitch/callback', + 'name' => 'Twitch', + 'auto_register' => env('TWITCH_AUTO_REGISTER', false), + 'auto_confirm' => env('TWITCH_AUTO_CONFIRM_EMAIL', false), + ], + + 'discord' => [ + 'client_id' => env('DISCORD_APP_ID'), + 'client_secret' => env('DISCORD_APP_SECRET'), + 'redirect' => env('APP_URL') . '/login/service/discord/callback', + 'name' => 'Discord', + 'auto_register' => env('DISCORD_AUTO_REGISTER', false), + 'auto_confirm' => env('DISCORD_AUTO_CONFIRM_EMAIL', false), + ], + + 'ldap' => [ + 'server' => env('LDAP_SERVER', false), + 'dump_user_details' => env('LDAP_DUMP_USER_DETAILS', false), + 'dump_user_groups' => env('LDAP_DUMP_USER_GROUPS', false), + 'dn' => env('LDAP_DN', false), + 'pass' => env('LDAP_PASS', false), + 'base_dn' => env('LDAP_BASE_DN', false), + 'user_filter' => env('LDAP_USER_FILTER', '(&(uid={user}))'), + 'version' => env('LDAP_VERSION', false), + 'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'), + 'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'), + 'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'), + 'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false), + 'user_to_groups' => env('LDAP_USER_TO_GROUPS', false), + 'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'), + 'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false), + 'tls_insecure' => env('LDAP_TLS_INSECURE', false), + 'tls_ca_cert' => env('LDAP_TLS_CA_CERT', false), + 'start_tls' => env('LDAP_START_TLS', false), + 'thumbnail_attribute' => env('LDAP_THUMBNAIL_ATTRIBUTE', null), + ], + +]; diff --git a/app/Config/session.php b/app/Config/session.php new file mode 100644 index 00000000000..f2ec2509fc8 --- /dev/null +++ b/app/Config/session.php @@ -0,0 +1,95 @@ + env('SESSION_DRIVER', 'file'), + + // Session lifetime, in minutes + 'lifetime' => env('SESSION_LIFETIME', 120), + + // Expire session on browser close + 'expire_on_close' => false, + + // Encrypt session data + 'encrypt' => false, + + // Location to store session files + 'files' => storage_path('framework/sessions'), + + // Session Database Connection + // When using the "database" or "redis" session drivers, you can specify a + // connection that should be used to manage these sessions. This should + // correspond to a connection in your database configuration options. + 'connection' => null, + + // Session database table, if database driver is in use + 'table' => 'sessions', + + // Session Cache Store + // When using the "apc" or "memcached" session drivers, you may specify a + // cache store that should be used for these sessions. This value must + // correspond with one of the application's configured cache stores. + 'store' => null, + + // Session Sweeping Lottery + // Some session drivers must manually sweep their storage location to get + // rid of old sessions from storage. Here are the chances that it will + // happen on a given request. By default, the odds are 2 out of 100. + 'lottery' => [2, 100], + + // Session Cookie Name + // Here you may change the name of the cookie used to identify a session + // instance by ID. The name specified here will get used every time a + // new session cookie is created by the framework for every driver. + 'cookie' => env('SESSION_COOKIE_NAME', 'bookstack_session'), + + // Session Cookie Path + // The session cookie path determines the path for which the cookie will + // be regarded as available. Typically, this will be the root path of + // your application but you are free to change this when necessary. + 'path' => '/' . (explode('/', env('APP_URL', ''), 4)[3] ?? ''), + + // Session Cookie Domain + // Here you may change the domain of the cookie used to identify a session + // in your application. This will determine which domains the cookie is + // available to in your application. A sensible default has been set. + 'domain' => env('SESSION_DOMAIN', null), + + // HTTPS Only Cookies + // By setting this option to true, session cookies will only be sent back + // to the server if the browser has a HTTPS connection. This will keep + // the cookie from being sent to you if it can not be done securely. + 'secure' => env('SESSION_SECURE_COOKIE', null) + ?? Str::startsWith(env('APP_URL', ''), 'https:'), + + // HTTP Access Only + // Setting this value to true will prevent JavaScript from accessing the + // value of the cookie and the cookie will only be accessible through the HTTP protocol. + 'http_only' => true, + + // Same-Site Cookies + // This option determines how your cookies behave when cross-site requests + // take place, and can be used to mitigate CSRF attacks. By default, we + // do not enable this as other CSRF protection services are in place. + // Options: lax, strict, none + 'same_site' => 'lax', + + + // Partitioned Cookies + // Setting this value to true will tie the cookie to the top-level site for + // a cross-site context. Partitioned cookies are accepted by the browser + // when flagged "secure" and the Same-Site attribute is set to "none". + 'partitioned' => false, +]; diff --git a/app/Config/setting-defaults.php b/app/Config/setting-defaults.php new file mode 100644 index 00000000000..2f270b283a2 --- /dev/null +++ b/app/Config/setting-defaults.php @@ -0,0 +1,47 @@ + 'BookStack', + 'app-logo' => '', + 'app-name-header' => true, + 'app-editor' => 'wysiwyg', + 'app-color' => '#206ea7', + 'app-color-light' => 'rgba(32,110,167,0.15)', + 'link-color' => '#206ea7', + 'bookshelf-color' => '#a94747', + 'book-color' => '#077b70', + 'chapter-color' => '#af4d0d', + 'page-color' => '#206ea7', + 'page-draft-color' => '#7e50b1', + 'app-color-dark' => '#195785', + 'app-color-light-dark' => 'rgba(32,110,167,0.15)', + 'link-color-dark' => '#429fe3', + 'bookshelf-color-dark' => '#ff5454', + 'book-color-dark' => '#389f60', + 'chapter-color-dark' => '#ee7a2d', + 'page-color-dark' => '#429fe3', + 'page-draft-color-dark' => '#a66ce8', + 'app-custom-head' => false, + 'registration-enabled' => false, + + // User-level default settings + 'user' => [ + 'ui-shortcuts' => '{}', + 'ui-shortcuts-enabled' => false, + 'dark-mode-enabled' => env('APP_DEFAULT_DARK_MODE', false), + 'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'), + 'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'), + 'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'), + 'notifications#comment-mentions' => true, + ], + +]; diff --git a/app/Config/view.php b/app/Config/view.php new file mode 100644 index 00000000000..2eb30b4c9de --- /dev/null +++ b/app/Config/view.php @@ -0,0 +1,31 @@ +` folder to hold the + // custom theme overrides. + 'theme' => env('APP_THEME', false), + + // View Storage Paths + // Most templating systems load templates from disk. Here you may specify + // an array of paths that should be checked for your views. Of course + // the usual Laravel view path has already been registered for you. + 'paths' => [realpath(base_path('resources/views'))], + + // Compiled View Path + // This option determines where all the compiled Blade templates will be + // stored for your application. Typically, this is within the storage + // directory. However, as usual, you are free to change this value. + 'compiled' => realpath(storage_path('framework/views')), + +]; diff --git a/app/Console/Commands/AssignSortRuleCommand.php b/app/Console/Commands/AssignSortRuleCommand.php new file mode 100644 index 00000000000..f00df83831c --- /dev/null +++ b/app/Console/Commands/AssignSortRuleCommand.php @@ -0,0 +1,99 @@ +argument('sort-rule')); + if ($sortRuleId === 0) { + return $this->listSortRules(); + } + + $rule = SortRule::query()->find($sortRuleId); + if ($this->option('all-books')) { + $query = Book::query(); + } else if ($this->option('books-without-sort')) { + $query = Book::query()->whereNull('sort_rule_id'); + } else if ($this->option('books-with-sort')) { + $sortId = intval($this->option('books-with-sort')) ?: 0; + if (!$sortId) { + $this->error("Provided --books-with-sort option value is invalid"); + return 1; + } + $query = Book::query()->where('sort_rule_id', $sortId); + } else { + $this->error("No option provided to specify target. Run with the -h option to see all available options."); + return 1; + } + + if (!$rule) { + $this->error("Sort rule of provided id {$sortRuleId} not found!"); + return 1; + } + + $count = $query->clone()->count(); + $this->warn("This will apply sort rule [{$rule->id}: {$rule->name}] to {$count} book(s) and run the sort on each."); + $confirmed = $this->confirm("Are you sure you want to continue?"); + + if (!$confirmed) { + return 1; + } + + $processed = 0; + $query->chunkById(10, function ($books) use ($rule, $sorter, $count, &$processed) { + $max = min($count, ($processed + 10)); + $this->info("Applying to {$processed}-{$max} of {$count} books"); + foreach ($books as $book) { + $book->sort_rule_id = $rule->id; + $book->save(); + $sorter->runBookAutoSort($book); + } + $processed = $max; + }); + + $this->info("Sort applied to {$processed} book(s)!"); + + return 0; + } + + protected function listSortRules(): int + { + + $rules = SortRule::query()->orderBy('id', 'asc')->get(); + $this->error("Sort rule ID required!"); + $this->warn("\nAvailable sort rules:"); + foreach ($rules as $rule) { + $this->info("{$rule->id}: {$rule->name}"); + } + + return 1; + } +} diff --git a/app/Console/Commands/CleanupImagesCommand.php b/app/Console/Commands/CleanupImagesCommand.php new file mode 100644 index 00000000000..18e60ff1773 --- /dev/null +++ b/app/Console/Commands/CleanupImagesCommand.php @@ -0,0 +1,76 @@ +option('all'); + $dryRun = !$this->option('force'); + + if (!$dryRun) { + $this->warn("This operation is destructive and is not guaranteed to be fully accurate.\nEnsure you have a backup of your images.\n"); + $proceed = !$this->input->isInteractive() || $this->confirm("Are you sure you want to proceed?"); + if (!$proceed) { + return 0; + } + } + + $deleted = $imageService->deleteUnusedImages($checkRevisions, $dryRun); + $deleteCount = count($deleted); + + if ($dryRun) { + $this->comment('Dry run, no images have been deleted'); + $this->comment($deleteCount . ' image(s) found that would have been deleted'); + $this->showDeletedImages($deleted); + $this->comment('Run with -f or --force to perform deletions'); + + return 0; + } + + $this->showDeletedImages($deleted); + $this->comment("{$deleteCount} image(s) deleted"); + + return 0; + } + + protected function showDeletedImages($paths): void + { + if ($this->getOutput()->getVerbosity() <= OutputInterface::VERBOSITY_NORMAL) { + return; + } + + if (count($paths) > 0) { + $this->line('Image(s) to delete:'); + } + + foreach ($paths as $path) { + $this->line($path); + } + } +} diff --git a/app/Console/Commands/ClearActivity.php b/app/Console/Commands/ClearActivity.php deleted file mode 100644 index 66babd9a94b..00000000000 --- a/app/Console/Commands/ClearActivity.php +++ /dev/null @@ -1,47 +0,0 @@ -activity = $activity; - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() - { - $this->activity->newQuery()->truncate(); - $this->comment('System activity cleared'); - } -} diff --git a/app/Console/Commands/ClearActivityCommand.php b/app/Console/Commands/ClearActivityCommand.php new file mode 100644 index 00000000000..6ec2e1a2aaa --- /dev/null +++ b/app/Console/Commands/ClearActivityCommand.php @@ -0,0 +1,33 @@ +truncate(); + $this->comment('System activity cleared'); + return 0; + } +} diff --git a/app/Console/Commands/ClearRevisions.php b/app/Console/Commands/ClearRevisions.php deleted file mode 100644 index f0c8a5e85b9..00000000000 --- a/app/Console/Commands/ClearRevisions.php +++ /dev/null @@ -1,50 +0,0 @@ -pageRevision = $pageRevision; - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() - { - $deleteTypes = $this->option('all') ? ['version', 'update_draft'] : ['version']; - $this->pageRevision->newQuery()->whereIn('type', $deleteTypes)->delete(); - $this->comment('Revisions deleted'); - } -} diff --git a/app/Console/Commands/ClearRevisionsCommand.php b/app/Console/Commands/ClearRevisionsCommand.php new file mode 100644 index 00000000000..ad001fdb1e8 --- /dev/null +++ b/app/Console/Commands/ClearRevisionsCommand.php @@ -0,0 +1,36 @@ +option('all') ? ['version', 'update_draft'] : ['version']; + PageRevision::query()->whereIn('type', $deleteTypes)->delete(); + $this->comment('Revisions deleted'); + return 0; + } +} diff --git a/app/Console/Commands/ClearViews.php b/app/Console/Commands/ClearViews.php deleted file mode 100644 index 678c64d3301..00000000000 --- a/app/Console/Commands/ClearViews.php +++ /dev/null @@ -1,42 +0,0 @@ -comment('Views cleared'); - } -} diff --git a/app/Console/Commands/ClearViewsCommand.php b/app/Console/Commands/ClearViewsCommand.php new file mode 100644 index 00000000000..87ea503dc47 --- /dev/null +++ b/app/Console/Commands/ClearViewsCommand.php @@ -0,0 +1,33 @@ +truncate(); + $this->comment('Views cleared'); + return 0; + } +} diff --git a/app/Console/Commands/CopyShelfPermissionsCommand.php b/app/Console/Commands/CopyShelfPermissionsCommand.php new file mode 100644 index 00000000000..1207621debc --- /dev/null +++ b/app/Console/Commands/CopyShelfPermissionsCommand.php @@ -0,0 +1,75 @@ +option('slug'); + $cascadeAll = $this->option('all'); + $noInteraction = boolval($this->option('no-interaction')); + $shelves = null; + + if (!$cascadeAll && !$shelfSlug) { + $this->error('Either a --slug or --all option must be provided.'); + + return 1; + } + + if ($cascadeAll) { + if (!$noInteraction) { + $continue = $this->confirm( + 'Permission settings for all shelves will be cascaded. ' . + 'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. ' . + 'Are you sure you want to proceed?', + ); + + if (!$continue) { + return 0; + } + } + + $shelves = $queries->start()->get(['id']); + } + + if ($shelfSlug) { + $shelves = $queries->start()->where('slug', '=', $shelfSlug)->get(['id']); + if ($shelves->count() === 0) { + $this->info('No shelves found with the given slug.'); + } + } + + foreach ($shelves as $shelf) { + $permissionsUpdater->updateBookPermissionsFromShelf($shelf, false); + $this->info('Copied permissions for shelf [' . $shelf->id . ']'); + } + + $this->info('Permissions copied for ' . $shelves->count() . ' shelves.'); + return 0; + } +} diff --git a/app/Console/Commands/CreateAdminCommand.php b/app/Console/Commands/CreateAdminCommand.php new file mode 100644 index 00000000000..bf72553f72a --- /dev/null +++ b/app/Console/Commands/CreateAdminCommand.php @@ -0,0 +1,162 @@ +option('initial'); + $shouldGeneratePassword = $this->option('generate-password'); + $details = $this->gatherDetails($shouldGeneratePassword, $initialAdminOnly); + + $validator = Validator::make($details, [ + 'email' => ['required', 'email', 'min:5'], + 'name' => ['required', 'min:2'], + 'password' => ['required_without:external_auth_id', Password::default()], + 'external_auth_id' => ['required_without:password'], + ]); + + if ($validator->fails()) { + foreach ($validator->errors()->all() as $error) { + $this->error($error); + } + + return 1; + } + + $adminRole = Role::getSystemRole('admin'); + + if ($initialAdminOnly) { + $handled = $this->handleInitialAdminIfExists($userRepo, $details, $shouldGeneratePassword, $adminRole); + if ($handled !== null) { + return $handled; + } + } + + $emailUsed = $userRepo->getByEmail($details['email']) !== null; + if ($emailUsed) { + $this->error("Could not create admin account."); + $this->error("An account with the email address \"{$details['email']}\" already exists."); + return 1; + } + + $user = $userRepo->createWithoutActivity($validator->validated()); + $user->attachRole($adminRole); + $user->email_confirmed = true; + $user->save(); + + if ($shouldGeneratePassword) { + $this->line($details['password']); + } else { + $this->info("Admin account with email \"{$user->email}\" successfully created!"); + } + + return 0; + } + + /** + * Handle updates to the original admin account if it exists. + * Returns an int return status if handled, otherwise returns null if not handled (new user to be created). + */ + protected function handleInitialAdminIfExists(UserRepo $userRepo, array $data, bool $generatePassword, Role $adminRole): int|null + { + $defaultAdmin = $userRepo->getByEmail('admin@admin.com'); + if ($defaultAdmin && $defaultAdmin->hasSystemRole('admin')) { + if ($defaultAdmin->email !== $data['email'] && $userRepo->getByEmail($data['email']) !== null) { + $this->error("Could not create admin account."); + $this->error("An account with the email address \"{$data['email']}\" already exists."); + return 1; + } + + $userRepo->updateWithoutActivity($defaultAdmin, $data, true); + if ($generatePassword) { + $this->line($data['password']); + } else { + $this->info("The default admin user has been updated with the provided details!"); + } + + return 0; + } else if ($adminRole->users()->count() > 0) { + $this->warn('Non-default admin user already exists. Skipping creation of new admin user.'); + return 2; + } + + return null; + } + + protected function gatherDetails(bool $generatePassword, bool $initialAdmin): array + { + $details = $this->snakeCaseOptions(); + + if (empty($details['email'])) { + if ($initialAdmin) { + $details['email'] = 'admin@example.com'; + } else { + $details['email'] = $this->ask('Please specify an email address for the new admin user'); + } + } + + if (empty($details['name'])) { + if ($initialAdmin) { + $details['name'] = 'Admin'; + } else { + $details['name'] = $this->ask('Please specify a name for the new admin user'); + } + } + + if (empty($details['password'])) { + if (empty($details['external_auth_id'])) { + if ($generatePassword) { + $details['password'] = Str::random(32); + } else { + $details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)'); + } + } else { + $details['password'] = Str::random(32); + } + } + + return $details; + } + + protected function snakeCaseOptions(): array + { + $returnOpts = []; + foreach ($this->options() as $key => $value) { + $returnOpts[str_replace('-', '_', $key)] = $value; + } + + return $returnOpts; + } +} diff --git a/app/Console/Commands/DeleteUsersCommand.php b/app/Console/Commands/DeleteUsersCommand.php new file mode 100644 index 00000000000..d5c85dc8c5a --- /dev/null +++ b/app/Console/Commands/DeleteUsersCommand.php @@ -0,0 +1,53 @@ +warn('This will delete all users from the system that are not "admin" or system users.'); + $confirm = $this->confirm('Are you sure you want to continue?'); + + if (!$confirm) { + return 0; + } + + $totalUsers = User::query()->count(); + $numDeleted = 0; + $users = User::query()->whereNull('system_name')->with('roles')->get(); + + foreach ($users as $user) { + if ($user->hasSystemRole('admin')) { + // don't delete users with "admin" role + continue; + } + $userRepo->destroy($user); + $numDeleted++; + } + + $this->info("Deleted $numDeleted of $totalUsers total users."); + return 0; + } +} diff --git a/app/Console/Commands/HandlesSingleUser.php b/app/Console/Commands/HandlesSingleUser.php new file mode 100644 index 00000000000..d3014aab188 --- /dev/null +++ b/app/Console/Commands/HandlesSingleUser.php @@ -0,0 +1,40 @@ +option('id'); + $email = $this->option('email'); + if (!$id && !$email) { + throw new Exception("Either a --id= or --email= option must be provided.\nRun this command with `--help` to show more options."); + } + + $field = $id ? 'id' : 'email'; + $value = $id ?: $email; + + $user = User::query() + ->where($field, '=', $value) + ->first(); + + if (!$user) { + throw new Exception("A user where {$field}={$value} could not be found."); + } + + return $user; + } +} diff --git a/app/Console/Commands/InstallModuleCommand.php b/app/Console/Commands/InstallModuleCommand.php new file mode 100644 index 00000000000..8e77474554e --- /dev/null +++ b/app/Console/Commands/InstallModuleCommand.php @@ -0,0 +1,319 @@ +argument('location'); + + // Get the ZIP file containing the module files + $zipPath = $this->getPathToZip($location); + if (!$zipPath) { + $this->cleanup(); + return 1; + } + + // Validate module zip file (metadata, size, etc...) and get module instance + $zip = new ThemeModuleZip($zipPath); + $themeModule = $this->validateAndGetModuleInfoFromZip($zip); + if (!$themeModule) { + $this->cleanup(); + return 1; + } + + // Get the theme folder in use, attempting to create one if no active theme in use + $themeFolder = $this->getThemeFolder(); + if (!$themeFolder) { + $this->cleanup(); + return 1; + } + + // Get the modules folder of the theme, attempting to create it if not existing, + // and create a new module manager instance. + $moduleFolder = $this->getModuleFolder($themeFolder); + if (!$moduleFolder) { + $this->cleanup(); + return 1; + } + + $manager = new ThemeModuleManager($moduleFolder); + + // Handle existing modules with the same name + $exitingModulesWithName = $manager->getByName($themeModule->name); + $shouldContinue = $this->handleExistingModulesWithSameName($exitingModulesWithName, $manager); + if (!$shouldContinue) { + $this->cleanup(); + return 1; + } + + // Extract module ZIP into the theme modules folder + try { + $newModule = $manager->addFromZip($themeModule->name, $zip); + } catch (ThemeModuleException $exception) { + $this->error("ERROR: Failed to install module with error: {$exception->getMessage()}"); + $this->cleanup(); + return 1; + } + + $this->info("Module \"{$newModule->name}\" ({$newModule->getVersion()}) successfully installed!"); + $this->info("Install location: {$moduleFolder}/{$newModule->folderName}"); + $this->cleanup(); + return 0; + } + + /** + * @param ThemeModule[] $existingModules + */ + protected function handleExistingModulesWithSameName(array $existingModules, ThemeModuleManager $manager): bool + { + if (count($existingModules) === 0) { + return true; + } + + $this->warn("The following modules already exist with the same name:"); + foreach ($existingModules as $folder => $module) { + $this->line("{$module->name} ({$folder}:{$module->getVersion()}) - {$module->description}"); + } + $this->line(''); + + $choices = ['Cancel module install', 'Add alongside existing module']; + if (count($existingModules) === 1) { + $choices[] = 'Replace existing module'; + } + $choice = $this->choice("What would you like to do?", $choices, 0, null, false); + if ($choice === 'Cancel module install') { + return false; + } + + if ($choice === 'Replace existing module') { + $existingModuleFolder = array_key_first($existingModules); + $this->info("Replacing existing module in {$existingModuleFolder} folder"); + $manager->deleteModuleFolder($existingModuleFolder); + } + + return true; + } + + protected function getModuleFolder(string $themeFolder): string|null + { + $path = $themeFolder . DIRECTORY_SEPARATOR . 'modules'; + + if (file_exists($path) && !is_dir($path)) { + $this->error("ERROR: Cannot create a modules folder, file already exists at {$path}"); + return null; + } + + if (!file_exists($path)) { + $created = mkdir($path, 0755, true); + if (!$created) { + $this->error("ERROR: Failed to create a modules folder at {$path}"); + return null; + } + } + + return $path; + } + + protected function getThemeFolder(): string|null + { + $path = theme_path(''); + if (!$path || !is_dir($path)) { + $shouldCreate = $this->confirm('No active theme folder found, would you like to create one?'); + if (!$shouldCreate) { + return null; + } + + $folder = 'custom'; + while (file_exists(base_path("themes" . DIRECTORY_SEPARATOR . $folder))) { + $folder = 'custom-' . Str::random(4); + } + + $path = base_path("themes/{$folder}"); + $created = mkdir($path, 0755, true); + if (!$created) { + $this->error('Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme'); + return null; + } + + $this->info("Created theme folder at {$path}"); + $this->warn("You will need to set APP_THEME={$folder} in your BookStack env configuration to enable this theme!"); + } + + return $path; + } + + protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeModule|null + { + if (!$zip->exists()) { + $this->error("ERROR: Cannot open ZIP file at {$zip->getPath()}"); + return null; + } + + if ($zip->getContentsSize() > (50 * 1024 * 1024)) { + $this->error("ERROR: Module ZIP file contents are too large. Maximum size is 50MB"); + return null; + } + + try { + $themeModule = $zip->getModuleInstance(); + } catch (ThemeModuleException $exception) { + $this->error("ERROR: Failed to read module metadata with error: {$exception->getMessage()}"); + return null; + } + + return $themeModule; + } + + protected function downloadModuleFile(string $location): string|null + { + $httpRequests = app()->make(HttpRequestService::class); + $client = $httpRequests->buildClient(30, ['stream' => true]); + $currentLocation = $location; + $maxRedirects = 3; + $redirectCount = 0; + + // Follow redirects up to 3 times for the same hostname + do { + $resp = $client->sendRequest(new Request('GET', $currentLocation)); + $statusCode = $resp->getStatusCode(); + + if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) { + $redirectLocation = $resp->getHeaderLine('Location'); + if ($redirectLocation) { + $comparison = new UrlComparison($location, $redirectLocation); + $redirectOriginMatches = $comparison->originsMatch(); + + if (!$redirectOriginMatches) { + $redirectUrl = parse_url($redirectLocation); + $redirectOrigin = ($redirectUrl['scheme'] ?? '') . '://' . ($redirectUrl['host'] ?? '') . (isset($redirectUrl['port']) ? ':' . $redirectUrl['port'] : ''); + $this->info("The download URL is redirecting to a different site: {$redirectOrigin}"); + $shouldContinue = $this->confirm("Do you trust downloading the module from this site?"); + if (!$shouldContinue) { + $this->error("Stopping module installation"); + return null; + } + } + + $currentLocation = $redirectLocation; + $redirectCount++; + continue; + } + } + + break; + } while (true); + + if ($resp->getStatusCode() >= 300) { + $this->error("ERROR: Failed to download module from {$location}"); + $this->error("Download failed with status code {$resp->getStatusCode()}"); + return null; + } + + $tempFile = tempnam(sys_get_temp_dir(), 'bookstack_module_'); + $fileHandle = fopen($tempFile, 'w'); + $respBody = $resp->getBody(); + $size = 0; + $maxSize = 50 * 1024 * 1024; + + while (!$respBody->eof()) { + fwrite($fileHandle, $respBody->read(1024)); + $size += 1024; + if ($size > $maxSize) { + fclose($fileHandle); + unlink($tempFile); + $this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB"); + return ''; + } + } + + fclose($fileHandle); + + $this->cleanupActions[] = function () use ($tempFile) { + unlink($tempFile); + }; + + return $tempFile; + } + + protected function getPathToZip(string $location): string|null + { + $lowerLocation = strtolower($location); + $isRemote = str_starts_with($lowerLocation, 'http://') || str_starts_with($lowerLocation, 'https://'); + + if ($isRemote) { + // Warning about fetching from source + $host = parse_url($location, PHP_URL_HOST); + $this->warn("\nThis will download a module from: {$host}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources."); + $trustHost = $this->confirm('Are you sure you trust this source?'); + if (!$trustHost) { + return null; + } + + // Check if the connection is http. If so, warn the user. + if (str_starts_with($lowerLocation, 'http://')) { + $this->warn("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks."); + if (!$this->confirm('Are you sure you want to continue without HTTPS?')) { + return null; + } + } + + // Download ZIP and get its location + return $this->downloadModuleFile($location); + } + + // Validate the file and get the full location + $zipPath = realpath($location); + + if (!$zipPath || !is_file($zipPath)) { + $this->error("ERROR: Module file not found at {$location}"); + return null; + } + + $this->warn("\nThis will install a module from: {$zipPath}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources."); + $trustHost = $this->confirm('Are you sure you want to install this module?'); + if (!$trustHost) { + return null; + } + + return $zipPath; + } + + protected function cleanup(): void + { + foreach ($this->cleanupActions as $action) { + $action(); + } + } +} diff --git a/app/Console/Commands/RefreshAvatarCommand.php b/app/Console/Commands/RefreshAvatarCommand.php new file mode 100644 index 00000000000..e402285e734 --- /dev/null +++ b/app/Console/Commands/RefreshAvatarCommand.php @@ -0,0 +1,116 @@ +avatarFetchEnabled()) { + $this->error("Avatar fetching is disabled on this instance."); + return self::FAILURE; + } + + if ($this->option('users-without-avatars')) { + return $this->processUsers(User::query()->whereDoesntHave('avatar')->get()->all(), $userAvatar); + } + + if ($this->option('all')) { + return $this->processUsers(User::query()->get()->all(), $userAvatar); + } + + try { + $user = $this->fetchProvidedUser(); + return $this->processUsers([$user], $userAvatar); + } catch (Exception $exception) { + $this->error($exception->getMessage()); + return self::FAILURE; + } + } + + /** + * @param User[] $users + */ + private function processUsers(array $users, UserAvatars $userAvatar): int + { + $dryRun = !$this->option('force'); + $this->info(count($users) . " user(s) found to update avatars for."); + + if (count($users) === 0) { + return self::SUCCESS; + } + + if (!$dryRun) { + $fetchHost = parse_url($userAvatar->getAvatarUrl(), PHP_URL_HOST); + $this->warn("This will destroy any existing avatar images these users have, and attempt to fetch new avatar images from {$fetchHost}."); + $proceed = !$this->input->isInteractive() || $this->confirm('Are you sure you want to proceed?'); + if (!$proceed) { + return self::SUCCESS; + } + } + + $this->info(""); + + $exitCode = self::SUCCESS; + foreach ($users as $user) { + $linePrefix = "[ID: {$user->id}] $user->email -"; + + if ($dryRun) { + $this->warn("{$linePrefix} Not updated"); + continue; + } + + if ($this->fetchAvatar($userAvatar, $user)) { + $this->info("{$linePrefix} Updated"); + } else { + $this->error("{$linePrefix} Not updated"); + $exitCode = self::FAILURE; + } + } + + if ($dryRun) { + $this->comment(""); + $this->comment("Dry run, no avatars were updated."); + $this->comment('Run with -f or --force to perform the update.'); + } + + return $exitCode; + } + + private function fetchAvatar(UserAvatars $userAvatar, User $user): bool + { + $oldId = $user->avatar->id ?? 0; + + $userAvatar->fetchAndAssignToUser($user); + + $user->refresh(); + $newId = $user->avatar->id ?? $oldId; + return $oldId !== $newId; + } +} diff --git a/app/Console/Commands/RegeneratePermissions.php b/app/Console/Commands/RegeneratePermissions.php deleted file mode 100644 index 9cd577a1786..00000000000 --- a/app/Console/Commands/RegeneratePermissions.php +++ /dev/null @@ -1,60 +0,0 @@ -permissionService = $permissionService; - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() - { - $connection = \DB::getDefaultConnection(); - if ($this->option('database') !== null) { - \DB::setDefaultConnection($this->option('database')); - $this->permissionService->setConnection(\DB::connection($this->option('database'))); - } - - $this->permissionService->buildJointPermissions(); - - \DB::setDefaultConnection($connection); - $this->comment('Permissions regenerated'); - } -} diff --git a/app/Console/Commands/RegeneratePermissionsCommand.php b/app/Console/Commands/RegeneratePermissionsCommand.php new file mode 100644 index 00000000000..856e943c529 --- /dev/null +++ b/app/Console/Commands/RegeneratePermissionsCommand.php @@ -0,0 +1,44 @@ +option('database')) { + DB::setDefaultConnection($this->option('database')); + } + + $permissionBuilder->rebuildForAll(); + + DB::setDefaultConnection($connection); + $this->comment('Permissions regenerated'); + + return 0; + } +} diff --git a/app/Console/Commands/RegenerateReferencesCommand.php b/app/Console/Commands/RegenerateReferencesCommand.php new file mode 100644 index 00000000000..563da100a79 --- /dev/null +++ b/app/Console/Commands/RegenerateReferencesCommand.php @@ -0,0 +1,45 @@ +option('database')) { + DB::setDefaultConnection($this->option('database')); + } + + $references->updateForAll(); + + DB::setDefaultConnection($connection); + + $this->comment('References have been regenerated'); + + return 0; + } +} diff --git a/app/Console/Commands/RegenerateSearch.php b/app/Console/Commands/RegenerateSearch.php deleted file mode 100644 index 1a0005544bb..00000000000 --- a/app/Console/Commands/RegenerateSearch.php +++ /dev/null @@ -1,54 +0,0 @@ -searchService = $searchService; - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() - { - $connection = \DB::getDefaultConnection(); - if ($this->option('database') !== null) { - \DB::setDefaultConnection($this->option('database')); - $this->searchService->setConnection(\DB::connection($this->option('database'))); - } - - $this->searchService->indexAllEntities(); - \DB::setDefaultConnection($connection); - $this->comment('Search index regenerated'); - } -} diff --git a/app/Console/Commands/RegenerateSearchCommand.php b/app/Console/Commands/RegenerateSearchCommand.php new file mode 100644 index 00000000000..f67a51e3d93 --- /dev/null +++ b/app/Console/Commands/RegenerateSearchCommand.php @@ -0,0 +1,46 @@ +option('database') !== null) { + DB::setDefaultConnection($this->option('database')); + } + + $searchIndex->indexAllEntities(function (Entity $model, int $processed, int $total): void { + $this->info('Indexed ' . class_basename($model) . ' entries (' . $processed . '/' . $total . ')'); + }); + + DB::setDefaultConnection($connection); + $this->line('Search index regenerated!'); + + return static::SUCCESS; + } +} diff --git a/app/Console/Commands/ResetMfaCommand.php b/app/Console/Commands/ResetMfaCommand.php new file mode 100644 index 00000000000..2b0801e39da --- /dev/null +++ b/app/Console/Commands/ResetMfaCommand.php @@ -0,0 +1,53 @@ +fetchProvidedUser(); + } catch (Exception $exception) { + $this->error($exception->getMessage()); + return 1; + } + + $this->info("This will delete any configure multi-factor authentication methods for user: \n- ID: {$user->id}\n- Name: {$user->name}\n- Email: {$user->email}\n"); + $this->info('If multi-factor authentication is required for this user they will be asked to reconfigure their methods on next login.'); + $confirm = $this->confirm('Are you sure you want to proceed?'); + if (!$confirm) { + return 1; + } + + $user->mfaValues()->delete(); + $this->info('User MFA methods have been reset.'); + + return 0; + } +} diff --git a/app/Console/Commands/UpdateUrlCommand.php b/app/Console/Commands/UpdateUrlCommand.php new file mode 100644 index 00000000000..fd86e070667 --- /dev/null +++ b/app/Console/Commands/UpdateUrlCommand.php @@ -0,0 +1,125 @@ +argument('oldUrl')); + $newUrl = str_replace("'", '', $this->argument('newUrl')); + + $urlPattern = '/https?:\/\/(.+)/'; + if (!preg_match($urlPattern, $oldUrl) || !preg_match($urlPattern, $newUrl)) { + $this->error('The given urls are expected to be full urls starting with http:// or https://'); + + return 1; + } + + if (!$this->checkUserOkayToProceed($oldUrl, $newUrl)) { + return 1; + } + + $columnsToUpdateByTable = [ + 'attachments' => ['path'], + 'entity_page_data' => ['html', 'text', 'markdown'], + 'entity_container_data' => ['description_html'], + 'page_revisions' => ['html', 'text', 'markdown'], + 'images' => ['url'], + 'settings' => ['value'], + 'comments' => ['html'], + ]; + + foreach ($columnsToUpdateByTable as $table => $columns) { + foreach ($columns as $column) { + $changeCount = $this->replaceValueInTable($db, $table, $column, $oldUrl, $newUrl); + $this->info("Updated {$changeCount} rows in {$table}->{$column}"); + } + } + + $jsonColumnsToUpdateByTable = [ + 'settings' => ['value'], + ]; + + foreach ($jsonColumnsToUpdateByTable as $table => $columns) { + foreach ($columns as $column) { + $oldJson = trim(json_encode($oldUrl), '"'); + $newJson = trim(json_encode($newUrl), '"'); + $changeCount = $this->replaceValueInTable($db, $table, $column, $oldJson, $newJson); + $this->info("Updated {$changeCount} JSON encoded rows in {$table}->{$column}"); + } + } + + $this->info('URL update procedure complete.'); + $this->info('============================================================================'); + $this->info('Be sure to run "php artisan cache:clear" to clear any old URLs in the cache.'); + + if (!str_starts_with($newUrl, url('/'))) { + $this->warn('You still need to update your APP_URL env value. This is currently set to:'); + $this->warn(url('/')); + } + + $this->info('============================================================================'); + + return 0; + } + + /** + * Perform a find+replace operations in the provided table and column. + * Returns the count of rows changed. + */ + protected function replaceValueInTable( + Connection $db, + string $table, + string $column, + string $oldUrl, + string $newUrl + ): int { + $oldQuoted = $db->getPdo()->quote($oldUrl); + $newQuoted = $db->getPdo()->quote($newUrl); + + return $db->table($table)->update([ + $column => $db->raw("REPLACE({$column}, {$oldQuoted}, {$newQuoted})"), + ]); + } + + /** + * Warn the user of the dangers of this operation. + * Returns a boolean indicating if they've accepted the warnings. + */ + protected function checkUserOkayToProceed(string $oldUrl, string $newUrl): bool + { + if ($this->option('force')) { + return true; + } + + $dangerWarning = "This will search for \"{$oldUrl}\" in your database and replace it with \"{$newUrl}\".\n"; + $dangerWarning .= 'Are you sure you want to proceed?'; + $backupConfirmation = 'This operation could cause issues if used incorrectly. Have you made a backup of your existing database?'; + + return $this->confirm($dangerWarning) && $this->confirm($backupConfirmation); + } +} diff --git a/app/Console/Commands/UpgradeDatabaseEncoding.php b/app/Console/Commands/UpgradeDatabaseEncoding.php deleted file mode 100644 index a17fc952351..00000000000 --- a/app/Console/Commands/UpgradeDatabaseEncoding.php +++ /dev/null @@ -1,57 +0,0 @@ -option('database') !== null) { - DB::setDefaultConnection($this->option('database')); - } - - $database = DB::getDatabaseName(); - $tables = DB::select('SHOW TABLES'); - $this->line('ALTER DATABASE `'.$database.'` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'); - $this->line('USE `'.$database.'`;'); - $key = 'Tables_in_' . $database; - foreach ($tables as $table) { - $tableName = $table->$key; - $this->line('ALTER TABLE `'.$tableName.'` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'); - } - - DB::setDefaultConnection($connection); - } -} diff --git a/app/Console/Commands/UpgradeDatabaseEncodingCommand.php b/app/Console/Commands/UpgradeDatabaseEncodingCommand.php new file mode 100644 index 00000000000..245ce57c6f0 --- /dev/null +++ b/app/Console/Commands/UpgradeDatabaseEncodingCommand.php @@ -0,0 +1,50 @@ +option('database') !== null) { + DB::setDefaultConnection($this->option('database')); + } + + $database = DB::getDatabaseName(); + $tables = DB::select('SHOW TABLES'); + $this->line('ALTER DATABASE `' . $database . '` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'); + $this->line('USE `' . $database . '`;'); + $key = 'Tables_in_' . $database; + foreach ($tables as $table) { + $tableName = $table->$key; + $this->line("ALTER TABLE `{$tableName}` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"); + } + + DB::setDefaultConnection($connection); + + return 0; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e75d9380163..f49be1d63b4 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -1,23 +1,17 @@ -load(__DIR__.'/Commands'); + $this->load(__DIR__ . '/Commands'); } } diff --git a/app/Entities/BreadcrumbsViewComposer.php b/app/Entities/BreadcrumbsViewComposer.php new file mode 100644 index 00000000000..c9269c7c66a --- /dev/null +++ b/app/Entities/BreadcrumbsViewComposer.php @@ -0,0 +1,32 @@ +getData()['crumbs']; + $firstCrumb = $crumbs[0] ?? null; + + if ($firstCrumb instanceof Book) { + $shelf = $this->shelfContext->getContextualShelfForBook($firstCrumb); + if ($shelf) { + array_unshift($crumbs, $shelf); + $view->with('crumbs', $crumbs); + } + } + } +} diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php new file mode 100644 index 00000000000..c47ece22569 --- /dev/null +++ b/app/Entities/Controllers/BookApiController.php @@ -0,0 +1,165 @@ +queries + ->visibleForList() + ->with(['cover:id,name,url']) + ->addSelect(['created_by', 'updated_by']); + + return $this->apiListingResponse($books, [ + 'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', + ]); + } + + /** + * Create a new book in the system. + * The cover image of a book can be set by sending a file via an 'image' property within a 'multipart/form-data' request. + * If the 'image' property is null then the book cover image will be removed. + * + * @throws ValidationException + */ + public function create(Request $request) + { + $this->checkPermission(Permission::BookCreateAll); + $requestData = $this->validate($request, $this->rules()['create']); + + $book = $this->bookRepo->create($requestData); + + return response()->json($this->forJsonDisplay($book)); + } + + /** + * View the details of a single book. + * The response data will contain a 'content' property listing the chapter and pages directly within, in + * the same structure as you'd see within the BookStack interface when viewing a book. Top-level + * contents will have a 'type' property to distinguish between pages and chapters. + */ + public function read(string $id) + { + $book = $this->queries->findVisibleByIdOrFail(intval($id)); + $book = $this->forJsonDisplay($book); + $book->load([ + 'createdBy', + 'updatedBy', + 'ownedBy', + 'shelves' => function (BelongsToMany $query) { + $query->select(['id', 'name', 'slug'])->scopes('visible'); + } + ]); + + $contents = (new BookContents($book))->getTree(true, false)->all(); + $contentsApiData = (new ApiEntityListFormatter($contents)) + ->withType() + ->withField('pages', function (Entity $entity) { + if ($entity instanceof Chapter) { + $pages = $this->pageQueries->visibleForChapterList($entity->id)->get()->all(); + return (new ApiEntityListFormatter($pages))->format(); + } + return null; + })->format(); + $book->setAttribute('contents', $contentsApiData); + + return response()->json($book); + } + + /** + * Update the details of a single book. + * The cover image of a book can be set by sending a file via an 'image' property within a 'multipart/form-data' request. + * If the 'image' property is null then the book cover image will be removed. + * + * @throws ValidationException + */ + public function update(Request $request, string $id) + { + $book = $this->queries->findVisibleByIdOrFail(intval($id)); + $this->checkOwnablePermission(Permission::BookUpdate, $book); + + $requestData = $this->validate($request, $this->rules()['update']); + $book = $this->bookRepo->update($book, $requestData); + + return response()->json($this->forJsonDisplay($book)); + } + + /** + * Delete a single book. + * This will typically send the book to the recycle bin. + * + * @throws \Exception + */ + public function delete(string $id) + { + $book = $this->queries->findVisibleByIdOrFail(intval($id)); + $this->checkOwnablePermission(Permission::BookDelete, $book); + + $this->bookRepo->destroy($book); + + return response('', 204); + } + + protected function forJsonDisplay(Book $book): Book + { + $book = clone $book; + $book->unsetRelations()->refresh(); + + $book->load(['tags']); + $book->makeVisible(['cover', 'description_html']) + ->setAttribute('description_html', $book->descriptionInfo()->getHtml()) + ->setAttribute('cover', $book->coverInfo()->getImage()); + + return $book; + } + + protected function rules(): array + { + return [ + 'create' => [ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['string', 'max:1900'], + 'description_html' => ['string', 'max:2000'], + 'tags' => ['array'], + 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + 'default_template_id' => ['nullable', 'integer'], + ], + 'update' => [ + 'name' => ['string', 'min:1', 'max:255'], + 'description' => ['string', 'max:1900'], + 'description_html' => ['string', 'max:2000'], + 'tags' => ['array'], + 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + 'default_template_id' => ['nullable', 'integer'], + ], + ]; + } +} diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php new file mode 100644 index 00000000000..98470d91ce8 --- /dev/null +++ b/app/Entities/Controllers/BookController.php @@ -0,0 +1,290 @@ +getForCurrentUser('books_view_type'); + $listOptions = SimpleListOptions::fromRequest($request, 'books')->withSortOptions([ + 'name' => trans('common.sort_name'), + 'created_at' => trans('common.sort_created_at'), + 'updated_at' => trans('common.sort_updated_at'), + ]); + + $books = $this->queries->visibleForListWithCover() + ->orderBy($listOptions->getSort(), $listOptions->getOrder()) + ->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); + $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false; + $popular = $this->queries->popularForList()->take(4)->get(); + $new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get(); + + $this->shelfContext->clearShelfContext(); + + $this->setPageTitle(trans('entities.books')); + + return view('books.index', [ + 'books' => $books, + 'recents' => $recents, + 'popular' => $popular, + 'new' => $new, + 'view' => $view, + 'listOptions' => $listOptions, + ]); + } + + /** + * Show the form for creating a new book. + */ + public function create(?string $shelfSlug = null) + { + $this->checkPermission(Permission::BookCreateAll); + + $bookshelf = null; + if ($shelfSlug !== null) { + $bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug); + $this->checkOwnablePermission(Permission::BookshelfUpdate, $bookshelf); + } + + $this->setPageTitle(trans('entities.books_create')); + + return view('books.create', [ + 'bookshelf' => $bookshelf, + ]); + } + + /** + * Store a newly created book in storage. + * + * @throws ImageUploadException + * @throws ValidationException + */ + public function store(Request $request, ?string $shelfSlug = null) + { + $this->checkPermission(Permission::BookCreateAll); + $validated = $this->validate($request, [ + 'name' => ['required', 'string', 'max:255'], + 'description_html' => ['string', 'max:2000'], + 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + 'tags' => ['array'], + 'default_template_id' => ['nullable', 'integer'], + ]); + + $bookshelf = null; + if ($shelfSlug !== null) { + $bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug); + $this->checkOwnablePermission(Permission::BookshelfUpdate, $bookshelf); + } + + $book = $this->bookRepo->create($validated); + + if ($bookshelf) { + $bookshelf->appendBook($book); + Activity::add(ActivityType::BOOKSHELF_UPDATE, $bookshelf); + } + + return redirect($book->getUrl()); + } + + /** + * Display the specified book. + */ + public function show(Request $request, ActivityQueries $activities, string $slug) + { + try { + $book = $this->queries->findVisibleBySlugOrFail($slug); + } catch (NotFoundException $exception) { + $book = $this->entityQueries->findVisibleByOldSlugs('book', $slug); + if (is_null($book)) { + throw $exception; + } + return redirect($book->getUrl()); + } + + $bookChildren = (new BookContents($book))->getTree(true); + $bookParentShelves = $book->shelves()->scopes('visible')->get(); + + View::incrementFor($book); + if ($request->has('shelf')) { + $this->shelfContext->setShelfContext(intval($request->input('shelf'))); + } + + $this->setPageTitle($book->getShortName()); + + return view('books.show', [ + 'book' => $book, + 'current' => $book, + 'bookChildren' => $bookChildren, + 'bookParentShelves' => $bookParentShelves, + 'watchOptions' => new UserEntityWatchOptions(user(), $book), + 'activity' => $activities->entityActivity($book, 20, 1), + 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book), + ]); + } + + /** + * Show the form for editing the specified book. + */ + public function edit(string $slug) + { + $book = $this->queries->findVisibleBySlugOrFail($slug); + $this->checkOwnablePermission(Permission::BookUpdate, $book); + $this->setPageTitle(trans('entities.books_edit_named', ['bookName' => $book->getShortName()])); + + return view('books.edit', ['book' => $book, 'current' => $book]); + } + + /** + * Update the specified book in storage. + * + * @throws ImageUploadException + * @throws ValidationException + * @throws Throwable + */ + public function update(Request $request, string $slug) + { + $book = $this->queries->findVisibleBySlugOrFail($slug); + $this->checkOwnablePermission(Permission::BookUpdate, $book); + + $validated = $this->validate($request, [ + 'name' => ['required', 'string', 'max:255'], + 'description_html' => ['string', 'max:2000'], + 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + 'tags' => ['array'], + 'default_template_id' => ['nullable', 'integer'], + ]); + + if ($request->has('image_reset')) { + $validated['image'] = null; + } elseif (array_key_exists('image', $validated) && is_null($validated['image'])) { + unset($validated['image']); + } + + $book = $this->bookRepo->update($book, $validated); + + return redirect($book->getUrl()); + } + + /** + * Shows the page to confirm deletion. + */ + public function showDelete(string $bookSlug) + { + $book = $this->queries->findVisibleBySlugOrFail($bookSlug); + $this->checkOwnablePermission(Permission::BookDelete, $book); + $this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()])); + + return view('books.delete', ['book' => $book, 'current' => $book]); + } + + /** + * Remove the specified book from the system. + * + * @throws Throwable + */ + public function destroy(string $bookSlug) + { + $book = $this->queries->findVisibleBySlugOrFail($bookSlug); + $this->checkOwnablePermission(Permission::BookDelete, $book); + $contextShelf = $this->shelfContext->getContextualShelfForBook($book); + + $this->bookRepo->destroy($book); + + if ($contextShelf) { + return redirect($contextShelf->getUrl()); + } + + return redirect('/books'); + } + + /** + * Show the view to copy a book. + * + * @throws NotFoundException + */ + public function showCopy(string $bookSlug) + { + $book = $this->queries->findVisibleBySlugOrFail($bookSlug); + $this->checkOwnablePermission(Permission::BookView, $book); + + session()->flashInput(['name' => $book->name]); + + return view('books.copy', [ + 'book' => $book, + ]); + } + + /** + * Create a copy of a book within the requested target destination. + * + * @throws NotFoundException + */ + public function copy(Request $request, Cloner $cloner, string $bookSlug) + { + $book = $this->queries->findVisibleBySlugOrFail($bookSlug); + $this->checkOwnablePermission(Permission::BookView, $book); + $this->checkPermission(Permission::BookCreateAll); + + $newName = $request->input('name') ?: $book->name; + $bookCopy = $cloner->cloneBook($book, $newName); + $this->showSuccessNotification(trans('entities.books_copy_success')); + + return redirect($bookCopy->getUrl()); + } + + /** + * Convert the chapter to a book. + */ + public function convertToShelf(HierarchyTransformer $transformer, string $bookSlug) + { + $book = $this->queries->findVisibleBySlugOrFail($bookSlug); + $this->checkOwnablePermission(Permission::BookUpdate, $book); + $this->checkOwnablePermission(Permission::BookDelete, $book); + $this->checkPermission(Permission::BookshelfCreateAll); + $this->checkPermission(Permission::BookCreateAll); + + $shelf = (new DatabaseTransaction(function () use ($book, $transformer) { + return $transformer->transformBookToShelf($book); + }))->run(); + + return redirect($shelf->getUrl()); + } +} diff --git a/app/Entities/Controllers/BookshelfApiController.php b/app/Entities/Controllers/BookshelfApiController.php new file mode 100644 index 00000000000..e620eb59c29 --- /dev/null +++ b/app/Entities/Controllers/BookshelfApiController.php @@ -0,0 +1,148 @@ +queries + ->visibleForList() + ->with(['cover:id,name,url']) + ->addSelect(['created_by', 'updated_by']); + + return $this->apiListingResponse($shelves, [ + 'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', + ]); + } + + /** + * Create a new shelf in the system. + * An array of books IDs can be provided in the request. These + * will be added to the shelf in the same order as provided. + * The cover image of a shelf can be set by sending a file via an 'image' property within a 'multipart/form-data' request. + * If the 'image' property is null then the shelf cover image will be removed. + * + * @throws ValidationException + */ + public function create(Request $request) + { + $this->checkPermission(Permission::BookshelfCreateAll); + $requestData = $this->validate($request, $this->rules()['create']); + + $bookIds = $request->input('books', []); + $shelf = $this->bookshelfRepo->create($requestData, $bookIds); + + return response()->json($this->forJsonDisplay($shelf)); + } + + /** + * View the details of a single shelf. + */ + public function read(string $id) + { + $shelf = $this->queries->findVisibleByIdOrFail(intval($id)); + $shelf = $this->forJsonDisplay($shelf); + $shelf->load([ + 'createdBy', 'updatedBy', 'ownedBy', + 'books' => function (BelongsToMany $query) { + $query->scopes('visible')->get(['id', 'name', 'slug']); + }, + ]); + + return response()->json($shelf); + } + + /** + * Update the details of a single shelf. + * An array of books IDs can be provided in the request. These + * will be added to the shelf in the same order as provided and overwrite + * any existing book assignments. + * The cover image of a shelf can be set by sending a file via an 'image' property within a 'multipart/form-data' request. + * If the 'image' property is null then the shelf cover image will be removed. + * + * @throws ValidationException + */ + public function update(Request $request, string $id) + { + $shelf = $this->queries->findVisibleByIdOrFail(intval($id)); + $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf); + + $requestData = $this->validate($request, $this->rules()['update']); + $bookIds = $request->input('books', null); + + $shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds); + + return response()->json($this->forJsonDisplay($shelf)); + } + + /** + * Delete a single shelf. + * This will typically send the shelf to the recycle bin. + * + * @throws Exception + */ + public function delete(string $id) + { + $shelf = $this->queries->findVisibleByIdOrFail(intval($id)); + $this->checkOwnablePermission(Permission::BookshelfDelete, $shelf); + + $this->bookshelfRepo->destroy($shelf); + + return response('', 204); + } + + protected function forJsonDisplay(Bookshelf $shelf): Bookshelf + { + $shelf = clone $shelf; + $shelf->unsetRelations()->refresh(); + + $shelf->load(['tags']); + $shelf->makeVisible(['cover', 'description_html']) + ->setAttribute('description_html', $shelf->descriptionInfo()->getHtml()) + ->setAttribute('cover', $shelf->coverInfo()->getImage()); + + return $shelf; + } + + protected function rules(): array + { + return [ + 'create' => [ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['string', 'max:1900'], + 'description_html' => ['string', 'max:2000'], + 'books' => ['array'], + 'tags' => ['array'], + 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + ], + 'update' => [ + 'name' => ['string', 'min:1', 'max:255'], + 'description' => ['string', 'max:1900'], + 'description_html' => ['string', 'max:2000'], + 'books' => ['array'], + 'tags' => ['array'], + 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + ], + ]; + } +} diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php new file mode 100644 index 00000000000..1e8b26b5156 --- /dev/null +++ b/app/Entities/Controllers/BookshelfController.php @@ -0,0 +1,232 @@ +getForCurrentUser('bookshelves_view_type'); + $listOptions = SimpleListOptions::fromRequest($request, 'bookshelves')->withSortOptions([ + 'name' => trans('common.sort_name'), + 'created_at' => trans('common.sort_created_at'), + 'updated_at' => trans('common.sort_updated_at'), + ]); + + $shelves = $this->queries->visibleForListWithCover() + ->orderBy($listOptions->getSort(), $listOptions->getOrder()) + ->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000)); + $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false; + $popular = $this->queries->popularForList()->get(); + $new = $this->queries->visibleForList() + ->orderBy('created_at', 'desc') + ->take(4) + ->get(); + + $this->shelfContext->clearShelfContext(); + $this->setPageTitle(trans('entities.shelves')); + + return view('shelves.index', [ + 'shelves' => $shelves, + 'recents' => $recents, + 'popular' => $popular, + 'new' => $new, + 'view' => $view, + 'listOptions' => $listOptions, + ]); + } + + /** + * Show the form for creating a new bookshelf. + */ + public function create() + { + $this->checkPermission(Permission::BookshelfCreateAll); + $books = $this->bookQueries->visibleForList()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']); + $this->setPageTitle(trans('entities.shelves_create')); + + return view('shelves.create', ['books' => $books]); + } + + /** + * Store a newly created bookshelf in storage. + * + * @throws ValidationException + * @throws ImageUploadException + */ + public function store(Request $request) + { + $this->checkPermission(Permission::BookshelfCreateAll); + $validated = $this->validate($request, [ + 'name' => ['required', 'string', 'max:255'], + 'description_html' => ['string', 'max:2000'], + 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + 'tags' => ['array'], + ]); + + $bookIds = explode(',', $request->input('books', '')); + $shelf = $this->shelfRepo->create($validated, $bookIds); + + return redirect($shelf->getUrl()); + } + + /** + * Display the bookshelf of the given slug. + * + * @throws NotFoundException + */ + public function show(Request $request, ActivityQueries $activities, string $slug) + { + try { + $shelf = $this->queries->findVisibleBySlugOrFail($slug); + } catch (NotFoundException $exception) { + $shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug); + if (is_null($shelf)) { + throw $exception; + } + return redirect($shelf->getUrl()); + } + + $this->checkOwnablePermission(Permission::BookshelfView, $shelf); + + $listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([ + 'default' => trans('common.sort_default'), + 'name' => trans('common.sort_name'), + 'created_at' => trans('common.sort_created_at'), + 'updated_at' => trans('common.sort_updated_at'), + ]); + + $sort = $listOptions->getSort(); + + $sortedVisibleShelfBooks = $shelf->visibleBooks() + ->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder()) + ->get() + ->values() + ->all(); + + View::incrementFor($shelf); + $this->shelfContext->setShelfContext($shelf->id); + $view = setting()->getForCurrentUser('bookshelf_view_type'); + + $this->setPageTitle($shelf->getShortName()); + + return view('shelves.show', [ + 'shelf' => $shelf, + 'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks, + 'view' => $view, + 'activity' => $activities->entityActivity($shelf, 20, 1), + 'listOptions' => $listOptions, + 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($shelf), + ]); + } + + /** + * Show the form for editing the specified bookshelf. + */ + public function edit(string $slug) + { + $shelf = $this->queries->findVisibleBySlugOrFail($slug); + $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf); + + $shelfBookIds = $shelf->books()->get(['id'])->pluck('id'); + $books = $this->bookQueries->visibleForList() + ->whereNotIn('id', $shelfBookIds) + ->orderBy('name') + ->get(['name', 'id', 'slug', 'created_at', 'updated_at']); + + $this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()])); + + return view('shelves.edit', [ + 'shelf' => $shelf, + 'books' => $books, + ]); + } + + /** + * Update the specified bookshelf in storage. + * + * @throws ValidationException + * @throws ImageUploadException + * @throws NotFoundException + */ + public function update(Request $request, string $slug) + { + $shelf = $this->queries->findVisibleBySlugOrFail($slug); + $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf); + $validated = $this->validate($request, [ + 'name' => ['required', 'string', 'max:255'], + 'description_html' => ['string', 'max:2000'], + 'image' => array_merge(['nullable'], $this->getImageValidationRules()), + 'tags' => ['array'], + ]); + + if ($request->has('image_reset')) { + $validated['image'] = null; + } elseif (array_key_exists('image', $validated) && is_null($validated['image'])) { + unset($validated['image']); + } + + $bookIds = explode(',', $request->input('books', '')); + $shelf = $this->shelfRepo->update($shelf, $validated, $bookIds); + + return redirect($shelf->getUrl()); + } + + /** + * Shows the page to confirm deletion. + */ + public function showDelete(string $slug) + { + $shelf = $this->queries->findVisibleBySlugOrFail($slug); + $this->checkOwnablePermission(Permission::BookshelfDelete, $shelf); + + $this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()])); + + return view('shelves.delete', ['shelf' => $shelf]); + } + + /** + * Remove the specified bookshelf from storage. + * + * @throws Exception + */ + public function destroy(string $slug) + { + $shelf = $this->queries->findVisibleBySlugOrFail($slug); + $this->checkOwnablePermission(Permission::BookshelfDelete, $shelf); + + $this->shelfRepo->destroy($shelf); + + return redirect('/shelves'); + } +} diff --git a/app/Entities/Controllers/ChapterApiController.php b/app/Entities/Controllers/ChapterApiController.php new file mode 100644 index 00000000000..9e0c69b1776 --- /dev/null +++ b/app/Entities/Controllers/ChapterApiController.php @@ -0,0 +1,155 @@ + [ + 'book_id' => ['required', 'integer'], + 'name' => ['required', 'string', 'max:255'], + 'description' => ['string', 'max:1900'], + 'description_html' => ['string', 'max:2000'], + 'tags' => ['array'], + 'priority' => ['integer'], + 'default_template_id' => ['nullable', 'integer'], + ], + 'update' => [ + 'book_id' => ['integer'], + 'name' => ['string', 'min:1', 'max:255'], + 'description' => ['string', 'max:1900'], + 'description_html' => ['string', 'max:2000'], + 'tags' => ['array'], + 'priority' => ['integer'], + 'default_template_id' => ['nullable', 'integer'], + ], + ]; + + public function __construct( + protected ChapterRepo $chapterRepo, + protected ChapterQueries $queries, + protected EntityQueries $entityQueries, + ) { + } + + /** + * Get a listing of chapters visible to the user. + */ + public function list() + { + $chapters = $this->queries->visibleForList() + ->addSelect(['created_by', 'updated_by']); + + return $this->apiListingResponse($chapters, [ + 'id', 'book_id', 'name', 'slug', 'description', 'priority', + 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', + ]); + } + + /** + * Create a new chapter in the system. + */ + public function create(Request $request) + { + $requestData = $this->validate($request, $this->rules['create']); + + $bookId = $request->input('book_id'); + $book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId)); + $this->checkOwnablePermission(Permission::ChapterCreate, $book); + + $chapter = $this->chapterRepo->create($requestData, $book); + + return response()->json($this->forJsonDisplay($chapter)); + } + + /** + * View the details of a single chapter. + */ + public function read(string $id) + { + $chapter = $this->queries->findVisibleByIdOrFail(intval($id)); + $chapter = $this->forJsonDisplay($chapter); + + $chapter->load(['createdBy', 'updatedBy', 'ownedBy']); + + // Note: More fields than usual here, for backwards compatibility, + // due to previously accidentally including more fields that desired. + $pages = $this->entityQueries->pages->visibleForChapterList($chapter->id) + ->addSelect(['created_by', 'updated_by', 'revision_count', 'editor']) + ->get(); + $chapter->setRelation('pages', $pages); + + return response()->json($chapter); + } + + /** + * Update the details of a single chapter. + * Providing a 'book_id' property will essentially move the chapter + * into that parent element if you have permissions to do so. + */ + public function update(Request $request, string $id) + { + $requestData = $this->validate($request, $this->rules()['update']); + $chapter = $this->queries->findVisibleByIdOrFail(intval($id)); + $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); + + if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) { + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); + + try { + $this->chapterRepo->move($chapter, "book:{$requestData['book_id']}"); + } catch (Exception $exception) { + if ($exception instanceof PermissionsException) { + $this->showPermissionError(); + } + + return $this->jsonError(trans('errors.selected_book_not_found')); + } + } + + $updatedChapter = $this->chapterRepo->update($chapter, $requestData); + + return response()->json($this->forJsonDisplay($updatedChapter)); + } + + /** + * Delete a chapter. + * This will typically send the chapter to the recycle bin. + */ + public function delete(string $id) + { + $chapter = $this->queries->findVisibleByIdOrFail(intval($id)); + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); + + $this->chapterRepo->destroy($chapter); + + return response('', 204); + } + + protected function forJsonDisplay(Chapter $chapter): Chapter + { + $chapter = clone $chapter; + $chapter->unsetRelations()->refresh(); + + $chapter->load(['tags']); + $chapter->makeVisible('description_html'); + $chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml()); + + /** @var Book $book */ + $book = $chapter->book()->first(); + $chapter->setAttribute('book_slug', $book->slug); + + return $chapter; + } +} diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php new file mode 100644 index 00000000000..db2391599ab --- /dev/null +++ b/app/Entities/Controllers/ChapterController.php @@ -0,0 +1,285 @@ +entityQueries->books->findVisibleBySlugOrFail($bookSlug); + $this->checkOwnablePermission(Permission::ChapterCreate, $book); + + $this->setPageTitle(trans('entities.chapters_create')); + + return view('chapters.create', [ + 'book' => $book, + 'current' => $book, + ]); + } + + /** + * Store a newly created chapter in storage. + * + * @throws ValidationException + */ + public function store(Request $request, string $bookSlug) + { + $validated = $this->validate($request, [ + 'name' => ['required', 'string', 'max:255'], + 'description_html' => ['string', 'max:2000'], + 'tags' => ['array'], + 'default_template_id' => ['nullable', 'integer'], + ]); + + $book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug); + $this->checkOwnablePermission(Permission::ChapterCreate, $book); + + $chapter = $this->chapterRepo->create($validated, $book); + + return redirect($chapter->getUrl()); + } + + /** + * Display the specified chapter. + */ + public function show(string $bookSlug, string $chapterSlug) + { + try { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + } catch (NotFoundException $exception) { + $chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug); + if (is_null($chapter)) { + throw $exception; + } + return redirect($chapter->getUrl()); + } + + $sidebarTree = (new BookContents($chapter->book))->getTree(); + $pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get(); + + $nextPreviousLocator = new NextPreviousContentLocator($chapter, $sidebarTree); + View::incrementFor($chapter); + + $this->setPageTitle($chapter->getShortName()); + + return view('chapters.show', [ + 'book' => $chapter->book, + 'chapter' => $chapter, + 'current' => $chapter, + 'sidebarTree' => $sidebarTree, + 'watchOptions' => new UserEntityWatchOptions(user(), $chapter), + 'pages' => $pages, + 'next' => $nextPreviousLocator->getNext(), + 'previous' => $nextPreviousLocator->getPrevious(), + 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($chapter), + ]); + } + + /** + * Show the form for editing the specified chapter. + */ + public function edit(string $bookSlug, string $chapterSlug) + { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); + + $this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()])); + + return view('chapters.edit', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]); + } + + /** + * Update the specified chapter in storage. + * + * @throws NotFoundException + */ + public function update(Request $request, string $bookSlug, string $chapterSlug) + { + $validated = $this->validate($request, [ + 'name' => ['required', 'string', 'max:255'], + 'description_html' => ['string', 'max:2000'], + 'tags' => ['array'], + 'default_template_id' => ['nullable', 'integer'], + ]); + + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); + + $chapter = $this->chapterRepo->update($chapter, $validated); + + return redirect($chapter->getUrl()); + } + + /** + * Shows the page to confirm deletion of this chapter. + * + * @throws NotFoundException + */ + public function showDelete(string $bookSlug, string $chapterSlug) + { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); + + $this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()])); + + return view('chapters.delete', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]); + } + + /** + * Remove the specified chapter from storage. + * + * @throws NotFoundException + * @throws Throwable + */ + public function destroy(string $bookSlug, string $chapterSlug) + { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); + + $this->chapterRepo->destroy($chapter); + + return redirect($chapter->book->getUrl()); + } + + /** + * Show the page for moving a chapter. + * + * @throws NotFoundException + */ + public function showMove(string $bookSlug, string $chapterSlug) + { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + $this->setPageTitle(trans('entities.chapters_move_named', ['chapterName' => $chapter->getShortName()])); + $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); + + return view('chapters.move', [ + 'chapter' => $chapter, + 'book' => $chapter->book, + ]); + } + + /** + * Perform the move action for a chapter. + * + * @throws NotFoundException|NotifyException + */ + public function move(Request $request, string $bookSlug, string $chapterSlug) + { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); + + $entitySelection = $request->input('entity_selection', null); + if ($entitySelection === null || $entitySelection === '') { + return redirect($chapter->getUrl()); + } + + try { + $this->chapterRepo->move($chapter, $entitySelection); + } catch (PermissionsException $exception) { + $this->showPermissionError(); + } catch (MoveOperationException $exception) { + $this->showErrorNotification(trans('errors.selected_book_not_found')); + + return redirect($chapter->getUrl('/move')); + } + + return redirect($chapter->getUrl()); + } + + /** + * Show the view to copy a chapter. + * + * @throws NotFoundException + */ + public function showCopy(string $bookSlug, string $chapterSlug) + { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + + session()->flashInput(['name' => $chapter->name]); + + return view('chapters.copy', [ + 'book' => $chapter->book, + 'chapter' => $chapter, + ]); + } + + /** + * Create a copy of a chapter within the requested target destination. + * + * @throws NotFoundException + * @throws Throwable + */ + public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug) + { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + + $entitySelection = $request->input('entity_selection') ?: null; + $newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent(); + + if (!$newParentBook instanceof Book) { + $this->showErrorNotification(trans('errors.selected_book_not_found')); + + return redirect($chapter->getUrl('/copy')); + } + + $this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook); + + $newName = $request->input('name') ?: $chapter->name; + $chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName); + $this->showSuccessNotification(trans('entities.chapters_copy_success')); + + return redirect($chapterCopy->getUrl()); + } + + /** + * Convert the chapter to a book. + */ + public function convertToBook(HierarchyTransformer $transformer, string $bookSlug, string $chapterSlug) + { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); + $this->checkPermission(Permission::BookCreateAll); + + $book = (new DatabaseTransaction(function () use ($chapter, $transformer) { + return $transformer->transformChapterToBook($chapter); + }))->run(); + + return redirect($book->getUrl()); + } +} diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php new file mode 100644 index 00000000000..38042e67058 --- /dev/null +++ b/app/Entities/Controllers/PageApiController.php @@ -0,0 +1,173 @@ + [ + 'book_id' => ['required_without:chapter_id', 'integer'], + 'chapter_id' => ['required_without:book_id', 'integer'], + 'name' => ['required', 'string', 'max:255'], + 'html' => ['required_without:markdown', 'string'], + 'markdown' => ['required_without:html', 'string'], + 'tags' => ['array'], + 'priority' => ['integer'], + ], + 'update' => [ + 'book_id' => ['integer'], + 'chapter_id' => ['integer'], + 'name' => ['string', 'min:1', 'max:255'], + 'html' => ['string'], + 'markdown' => ['string'], + 'tags' => ['array'], + 'priority' => ['integer'], + ], + ]; + + public function __construct( + protected PageRepo $pageRepo, + protected PageQueries $queries, + protected EntityQueries $entityQueries, + ) { + } + + /** + * Get a listing of pages visible to the user. + */ + public function list() + { + $pages = $this->queries->visibleForList() + ->addSelect(['created_by', 'updated_by', 'revision_count', 'editor']); + + return $this->apiListingResponse($pages, [ + 'id', 'book_id', 'chapter_id', 'name', 'slug', 'priority', + 'draft', 'template', + 'created_at', 'updated_at', + 'created_by', 'updated_by', 'owned_by', + ]); + } + + /** + * Create a new page in the system. + * + * The ID of a parent book or chapter is required to indicate + * where this page should be located. + * + * Any HTML content provided should be kept to a single-block depth of plain HTML + * elements to remain compatible with the BookStack front-end and editors. + * Any images included via base64 data URIs will be extracted and saved as gallery + * images against the page during upload. + */ + public function create(Request $request) + { + $this->validate($request, $this->rules['create']); + + if ($request->has('chapter_id')) { + $parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id'))); + } else { + $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id'))); + } + $this->checkOwnablePermission(Permission::PageCreate, $parent); + + $draft = $this->pageRepo->getNewDraftPage($parent); + $this->pageRepo->publishDraft($draft, $request->only(array_keys($this->rules['create']))); + + return response()->json($draft->forJsonDisplay()); + } + + /** + * View the details of a single page. + * Pages will always have HTML content. They may have markdown content + * if the Markdown editor was used to last update the page. + * + * The 'html' property is the fully rendered and escaped HTML content that BookStack + * would show on page view, with page includes handled. + * The 'raw_html' property is the direct database stored HTML content, which would be + * what BookStack shows on page edit. + * + * See the "Content Security" section of these docs for security considerations when using + * the page content returned from this endpoint. + * + * Comments for the page are provided in a tree-structure representing the hierarchy of top-level + * comments and replies, for both archived and active comments. + */ + public function read(string $id) + { + $page = $this->queries->findVisibleByIdOrFail($id); + + $page = $page->forJsonDisplay(); + $commentTree = (new CommentTree($page)); + $commentTree->loadVisibleHtml(); + $page->setAttribute('comments', [ + 'active' => $commentTree->getActive(), + 'archived' => $commentTree->getArchived(), + ]); + + return response()->json($page); + } + + /** + * Update the details of a single page. + * + * See the 'create' action for details on the provided HTML/Markdown. + * Providing a 'book_id' or 'chapter_id' property will essentially move + * the page into that parent element if you have permissions to do so. + */ + public function update(Request $request, string $id) + { + $requestData = $this->validate($request, $this->rules['update']); + + $page = $this->queries->findVisibleByIdOrFail($id); + $this->checkOwnablePermission(Permission::PageUpdate, $page); + + $parent = null; + if ($request->has('chapter_id')) { + $parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id'))); + } elseif ($request->has('book_id')) { + $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id'))); + } + + if ($parent && !$parent->matches($page->getParent())) { + $this->checkOwnablePermission(Permission::PageDelete, $page); + + try { + $this->pageRepo->move($page, $parent->getType() . ':' . $parent->id); + } catch (Exception $exception) { + if ($exception instanceof PermissionsException) { + $this->showPermissionError(); + } + + return $this->jsonError(trans('errors.selected_book_chapter_not_found')); + } + } + + $updatedPage = $this->pageRepo->update($page, $requestData); + + return response()->json($updatedPage->forJsonDisplay()); + } + + /** + * Delete a page. + * This will typically send the page to the recycle bin. + */ + public function delete(string $id) + { + $page = $this->queries->findVisibleByIdOrFail($id); + $this->checkOwnablePermission(Permission::PageDelete, $page); + + $this->pageRepo->destroy($page); + + return response('', 204); + } +} diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php new file mode 100644 index 00000000000..82edfbc2763 --- /dev/null +++ b/app/Entities/Controllers/PageController.php @@ -0,0 +1,473 @@ +entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + } else { + $parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug); + } + + $this->checkOwnablePermission(Permission::PageCreate, $parent); + + // Redirect to draft edit screen if signed in + if ($this->isSignedIn()) { + $draft = $this->pageRepo->getNewDraftPage($parent); + + return redirect($draft->getUrl()); + } + + // Otherwise show the edit view if they're a guest + $this->setPageTitle(trans('entities.pages_new')); + + return view('pages.guest-create', ['parent' => $parent]); + } + + /** + * Create a new page as a guest user. + * + * @throws ValidationException + */ + public function createAsGuest(Request $request, string $bookSlug, ?string $chapterSlug = null) + { + $this->validate($request, [ + 'name' => ['required', 'string', 'max:255'], + ]); + + if ($chapterSlug) { + $parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + } else { + $parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug); + } + + $this->checkOwnablePermission(Permission::PageCreate, $parent); + + $page = $this->pageRepo->getNewDraftPage($parent); + $this->pageRepo->publishDraft($page, [ + 'name' => $request->input('name'), + ]); + + return redirect($page->getUrl('/edit')); + } + + /** + * Show form to continue editing a draft page. + * + * @throws NotFoundException + */ + public function editDraft(Request $request, string $bookSlug, int $pageId) + { + $draft = $this->queries->findVisibleByIdOrFail($pageId); + $this->checkOwnablePermission(Permission::PageCreate, $draft->getParent()); + + $editorData = new PageEditorData($draft, $this->entityQueries, $request->query('editor', '')); + $this->setPageTitle(trans('entities.pages_edit_draft')); + + return view('pages.edit', $editorData->getViewData()); + } + + /** + * Store a new page by changing a draft into a page. + * + * @throws NotFoundException + * @throws ValidationException + */ + public function store(Request $request, string $bookSlug, int $pageId) + { + $this->validate($request, [ + 'name' => ['required', 'string', 'max:255'], + ]); + + $draftPage = $this->queries->findVisibleByIdOrFail($pageId); + $this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent()); + + $page = $this->pageRepo->publishDraft($draftPage, $request->all()); + + return redirect($page->getUrl()); + } + + /** + * Display the specified page. + * If the page is not found via the slug the revisions are searched for a match. + * + * @throws NotFoundException + */ + public function show(string $bookSlug, string $pageSlug) + { + try { + $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + } catch (NotFoundException $e) { + $page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug); + if (is_null($page)) { + throw $e; + } + + return redirect($page->getUrl()); + } + + $pageContent = (new PageContent($page)); + $page->html = $pageContent->render(); + $pageNav = $pageContent->getNavigation($page->html); + + $sidebarTree = (new BookContents($page->book))->getTree(); + $commentTree = (new CommentTree($page)); + $nextPreviousLocator = new NextPreviousContentLocator($page, $sidebarTree); + + View::incrementFor($page); + $this->setPageTitle($page->getShortName()); + + return view('pages.show', [ + 'page' => $page, + 'book' => $page->book, + 'current' => $page, + 'sidebarTree' => $sidebarTree, + 'commentTree' => $commentTree, + 'pageNav' => $pageNav, + 'watchOptions' => new UserEntityWatchOptions(user(), $page), + 'next' => $nextPreviousLocator->getNext(), + 'previous' => $nextPreviousLocator->getPrevious(), + 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($page), + ]); + } + + /** + * Get a page from an ajax request. + * + * @throws NotFoundException + */ + public function getPageAjax(int $pageId) + { + $page = $this->queries->findVisibleByIdOrFail($pageId); + $page->setHidden(array_diff($page->getHidden(), ['html', 'markdown'])); + $page->makeHidden(['book']); + + $filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering')); + $filter = new HtmlContentFilter($filterConfig); + $page->html = $filter->filterString($page->html); + + return response()->json($page); + } + + /** + * Show the form for editing the specified page. + * + * @throws NotFoundException + */ + public function edit(Request $request, string $bookSlug, string $pageSlug) + { + $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $this->checkOwnablePermission(Permission::PageUpdate, $page, $page->getUrl()); + + $editorData = new PageEditorData($page, $this->entityQueries, $request->query('editor', '')); + if ($editorData->getWarnings()) { + $this->showWarningNotification(implode("\n", $editorData->getWarnings())); + } + + $this->setPageTitle(trans('entities.pages_editing_named', ['pageName' => $page->getShortName()])); + + return view('pages.edit', $editorData->getViewData()); + } + + /** + * Update the specified page in storage. + * + * @throws ValidationException + * @throws NotFoundException + */ + public function update(Request $request, string $bookSlug, string $pageSlug) + { + $this->validate($request, [ + 'name' => ['required', 'string', 'max:255'], + ]); + $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $this->checkOwnablePermission(Permission::PageUpdate, $page); + + $this->pageRepo->update($page, $request->all()); + + return redirect($page->getUrl()); + } + + /** + * Save a draft update as a revision. + * + * @throws NotFoundException + */ + public function saveDraft(Request $request, int $pageId) + { + $page = $this->queries->findVisibleByIdOrFail($pageId); + $this->checkOwnablePermission(Permission::PageUpdate, $page); + + if (!$this->isSignedIn()) { + return $this->jsonError(trans('errors.guests_cannot_save_drafts'), 500); + } + + $draft = $this->pageRepo->updatePageDraft($page, $request->only(['name', 'html', 'markdown'])); + $warnings = (new PageEditActivity($page))->getWarningMessagesForDraft($draft); + + return response()->json([ + 'status' => 'success', + 'message' => trans('entities.pages_edit_draft_save_at'), + 'warning' => implode("\n", $warnings), + 'timestamp' => $draft->updated_at->timestamp, + ]); + } + + /** + * Redirect from a special link url which uses the page id rather than the name. + * + * @throws NotFoundException + */ + public function redirectFromLink(int $pageId) + { + $page = $this->queries->findVisibleByIdOrFail($pageId); + + return redirect($page->getUrl()); + } + + /** + * Show the deletion page for the specified page. + * + * @throws NotFoundException + */ + public function showDelete(string $bookSlug, string $pageSlug) + { + $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $this->checkOwnablePermission(Permission::PageDelete, $page); + $this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()])); + $usedAsTemplate = + $this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 || + $this->entityQueries->chapters->start()->where('default_template_id', '=', $page->id)->count() > 0; + + return view('pages.delete', [ + 'book' => $page->book, + 'page' => $page, + 'current' => $page, + 'usedAsTemplate' => $usedAsTemplate, + ]); + } + + /** + * Show the deletion page for the specified page. + * + * @throws NotFoundException + */ + public function showDeleteDraft(string $bookSlug, int $pageId) + { + $page = $this->queries->findVisibleByIdOrFail($pageId); + $this->checkOwnablePermission(Permission::PageUpdate, $page); + $this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()])); + $usedAsTemplate = + $this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 || + $this->entityQueries->chapters->start()->where('default_template_id', '=', $page->id)->count() > 0; + + return view('pages.delete', [ + 'book' => $page->book, + 'page' => $page, + 'current' => $page, + 'usedAsTemplate' => $usedAsTemplate, + ]); + } + + /** + * Remove the specified page from storage. + * + * @throws NotFoundException + * @throws Throwable + */ + public function destroy(string $bookSlug, string $pageSlug) + { + $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $this->checkOwnablePermission(Permission::PageDelete, $page); + $parent = $page->getParent(); + + $this->pageRepo->destroy($page); + + return redirect($parent->getUrl()); + } + + /** + * Remove the specified draft page from storage. + * + * @throws NotFoundException + * @throws Throwable + */ + public function destroyDraft(string $bookSlug, int $pageId) + { + $page = $this->queries->findVisibleByIdOrFail($pageId); + $book = $page->book; + $chapter = $page->chapter; + $this->checkOwnablePermission(Permission::PageUpdate, $page); + + $this->pageRepo->destroy($page); + + $this->showSuccessNotification(trans('entities.pages_delete_draft_success')); + + if ($chapter && userCan(Permission::ChapterView, $chapter)) { + return redirect($chapter->getUrl()); + } + + return redirect($book->getUrl()); + } + + /** + * Show a listing of recently created pages. + */ + public function showRecentlyUpdated() + { + $visibleBelongsScope = function (BelongsTo $query) { + $query->scopes('visible'); + }; + + $pages = $this->queries->visibleForList() + ->addSelect('updated_by') + ->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope]) + ->orderBy('updated_at', 'desc') + ->paginate(20) + ->setPath(url('/pages/recently-updated')); + + $this->setPageTitle(trans('entities.recently_updated_pages')); + + return view('common.detailed-listing-paginated', [ + 'title' => trans('entities.recently_updated_pages'), + 'entities' => $pages, + 'showUpdatedBy' => true, + 'showPath' => true, + ]); + } + + /** + * Show the view to choose a new parent to move a page into. + * + * @throws NotFoundException + */ + public function showMove(string $bookSlug, string $pageSlug) + { + $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $this->checkOwnablePermission(Permission::PageUpdate, $page); + $this->checkOwnablePermission(Permission::PageDelete, $page); + + return view('pages.move', [ + 'book' => $page->book, + 'page' => $page, + ]); + } + + /** + * Does the action of moving the location of a page. + * + * @throws NotFoundException + * @throws Throwable + */ + public function move(Request $request, string $bookSlug, string $pageSlug) + { + $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $this->checkOwnablePermission(Permission::PageUpdate, $page); + $this->checkOwnablePermission(Permission::PageDelete, $page); + + $entitySelection = $request->input('entity_selection', null); + if ($entitySelection === null || $entitySelection === '') { + return redirect($page->getUrl()); + } + + try { + $this->pageRepo->move($page, $entitySelection); + } catch (PermissionsException $exception) { + $this->showPermissionError(); + } catch (Exception $exception) { + $this->showErrorNotification(trans('errors.selected_book_chapter_not_found')); + + return redirect($page->getUrl('/move')); + } + + return redirect($page->getUrl()); + } + + /** + * Show the view to copy a page. + * + * @throws NotFoundException + */ + public function showCopy(string $bookSlug, string $pageSlug) + { + $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + session()->flashInput(['name' => $page->name]); + + return view('pages.copy', [ + 'book' => $page->book, + 'page' => $page, + ]); + } + + /** + * Create a copy of a page within the requested target destination. + * + * @throws NotFoundException + * @throws Throwable + */ + public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug) + { + $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $this->checkOwnablePermission(Permission::PageView, $page); + + $entitySelection = $request->input('entity_selection') ?: null; + $newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent(); + + if (!$newParent instanceof Book && !$newParent instanceof Chapter) { + $this->showErrorNotification(trans('errors.selected_book_chapter_not_found')); + + return redirect($page->getUrl('/copy')); + } + + $this->checkOwnablePermission(Permission::PageCreate, $newParent); + + $newName = $request->input('name') ?: $page->name; + $pageCopy = $cloner->clonePage($page, $newParent, $newName); + $this->showSuccessNotification(trans('entities.pages_copy_success')); + + return redirect($pageCopy->getUrl()); + } +} diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php new file mode 100644 index 00000000000..cc6b79bfe45 --- /dev/null +++ b/app/Entities/Controllers/PageRevisionController.php @@ -0,0 +1,185 @@ +checkPermission(Permission::RevisionViewAll); + $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([ + 'id' => trans('entities.pages_revisions_sort_number') + ]); + + $revisions = $page->revisions()->select([ + 'id', 'page_id', 'name', 'created_at', 'created_by', 'updated_at', + 'type', 'revision_number', 'summary', + ]) + ->selectRaw("IF(markdown = '', false, true) as is_markdown") + ->with(['page.book', 'createdBy']) + ->reorder('id', $listOptions->getOrder()) + ->paginate(50); + + $this->setPageTitle(trans('entities.pages_revisions_named', ['pageName' => $page->getShortName()])); + + return view('pages.revisions', [ + 'revisions' => $revisions, + 'page' => $page, + 'listOptions' => $listOptions, + 'oldestRevisionId' => $page->revisions()->min('id'), + ]); + } + + /** + * Shows a preview of a single revision. + * + * @throws NotFoundException + */ + public function show(string $bookSlug, string $pageSlug, int $revisionId) + { + $this->checkPermission(Permission::RevisionViewAll); + + $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + /** @var ?PageRevision $revision */ + $revision = $page->revisions()->where('id', '=', $revisionId)->first(); + if ($revision === null) { + throw new NotFoundException(); + } + + $page->fill($revision->toArray()); + // TODO - Refactor PageContent so we don't need to juggle this + $page->html = $revision->html; + $page->html = (new PageContent($page))->render(); + + $this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()])); + + return view('pages.revision', [ + 'page' => $page, + 'book' => $page->book, + 'diff' => null, + 'revision' => $revision, + ]); + } + + /** + * Shows the changes of a single revision. + * + * @throws NotFoundException + */ + public function changes(string $bookSlug, string $pageSlug, int $revisionId) + { + $this->checkPermission(Permission::RevisionViewAll); + + $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + /** @var ?PageRevision $revision */ + $revision = $page->revisions()->where('id', '=', $revisionId)->first(); + if ($revision === null) { + throw new NotFoundException(); + } + + $prev = $revision->getPreviousRevision(); + $prevContent = $prev->html ?? ''; + + // TODO - Refactor PageContent so we can de-dupe these steps + $rawDiff = Diff::excecute($prevContent, $revision->html); + $filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering')); + $filter = new HtmlContentFilter($filterConfig); + $diff = $filter->filterString($rawDiff); + + $page->fill($revision->toArray()); + $page->html = ''; + $this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()])); + + return view('pages.revision', [ + 'page' => $page, + 'book' => $page->book, + 'diff' => $diff, + 'revision' => $revision, + ]); + } + + /** + * Restores a page using the content of the specified revision. + * + * @throws NotFoundException + */ + public function restore(string $bookSlug, string $pageSlug, int $revisionId) + { + $this->checkPermission(Permission::RevisionViewAll); + $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $this->checkOwnablePermission(Permission::PageUpdate, $page); + + $page = $this->pageRepo->restoreRevision($page, $revisionId); + + return redirect($page->getUrl()); + } + + /** + * Deletes a revision using the id of the specified revision. + * + * @throws NotFoundException + */ + public function destroy(string $bookSlug, string $pageSlug, int $revId) + { + $this->checkPermission(Permission::RevisionViewAll); + $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $this->checkOwnablePermission(Permission::PageDelete, $page); + + $revision = $page->revisions()->where('id', '=', $revId)->first(); + if ($revision === null) { + throw new NotFoundException("Revision #{$revId} not found"); + } + + // Check if it's the latest revision, cannot delete the latest revision. + if (intval($page->currentRevision->id ?? null) === intval($revId)) { + $this->showErrorNotification(trans('entities.revision_cannot_delete_latest')); + + return redirect($page->getUrl('/revisions')); + } + + $revision->delete(); + Activity::add(ActivityType::REVISION_DELETE, $revision); + + return redirect($page->getUrl('/revisions')); + } + + /** + * Destroys existing drafts, belonging to the current user, for the given page. + */ + public function destroyUserDraft(string $pageId) + { + $page = $this->pageQueries->findVisibleByIdOrFail($pageId); + $this->revisionRepo->deleteDraftsForCurrentUser($page); + + return response('', 200); + } +} diff --git a/app/Entities/Controllers/PageTemplateController.php b/app/Entities/Controllers/PageTemplateController.php new file mode 100644 index 00000000000..9ff2fe0293e --- /dev/null +++ b/app/Entities/Controllers/PageTemplateController.php @@ -0,0 +1,67 @@ +input('page', 1); + $search = $request->input('search', ''); + $count = 10; + + $query = $this->pageQueries->visibleTemplates() + ->orderBy('name', 'asc') + ->skip(($page - 1) * $count) + ->take($count); + + if ($search) { + $query->where('name', 'like', '%' . $search . '%'); + } + + $templates = $query->paginate($count, ['*'], 'page', $page); + $templates->withPath('/templates'); + + if ($search) { + $templates->appends(['search' => $search]); + } + + return view('pages.parts.template-manager-list', [ + 'templates' => $templates, + ]); + } + + /** + * Get the content of a template. + * + * @throws NotFoundException + */ + public function get(int $templateId) + { + $page = $this->pageQueries->findVisibleByIdOrFail($templateId); + + if (!$page->template) { + throw new NotFoundException(); + } + + return response()->json([ + 'html' => $page->html, + 'markdown' => $page->markdown, + ]); + } +} diff --git a/app/Entities/Controllers/RecycleBinApiController.php b/app/Entities/Controllers/RecycleBinApiController.php new file mode 100644 index 00000000000..6146851366b --- /dev/null +++ b/app/Entities/Controllers/RecycleBinApiController.php @@ -0,0 +1,100 @@ +middleware(function ($request, $next) { + $this->checkPermission(Permission::SettingsManage); + $this->checkPermission(Permission::RestrictionsManageAll); + + return $next($request); + }); + } + + /** + * Get a top-level listing of the items in the recycle bin. + * The "deletable" property will reflect the main item deleted. + * For books and chapters, counts of child pages/chapters will + * be loaded within this "deletable" data. + * For chapters & pages, the parent item will be loaded within this "deletable" data. + * Requires permission to manage both system settings and permissions. + */ + public function list() + { + return $this->apiListingResponse(Deletion::query()->with('deletable'), [ + 'id', + 'deleted_by', + 'created_at', + 'updated_at', + 'deletable_type', + 'deletable_id', + ], [$this->listFormatter(...)]); + } + + /** + * Restore a single deletion from the recycle bin. + * Requires permission to manage both system settings and permissions. + */ + public function restore(DeletionRepo $deletionRepo, string $deletionId) + { + $restoreCount = $deletionRepo->restore(intval($deletionId)); + + return response()->json(['restore_count' => $restoreCount]); + } + + /** + * Remove a single deletion from the recycle bin. + * Use this endpoint carefully as it will entirely remove the underlying deleted items from the system. + * Requires permission to manage both system settings and permissions. + */ + public function destroy(DeletionRepo $deletionRepo, string $deletionId) + { + $deleteCount = $deletionRepo->destroy(intval($deletionId)); + + return response()->json(['delete_count' => $deleteCount]); + } + + /** + * Load some related details for the deletion listing. + */ + protected function listFormatter(Deletion $deletion): void + { + $deletable = $deletion->deletable; + + if ($deletable instanceof BookChild) { + $parent = $deletable->getParent(); + $parent->setAttribute('type', $parent->getType()); + $deletable->setRelation('parent', $parent); + } + + if ($deletable instanceof Book || $deletable instanceof Chapter) { + $countsToLoad = ['pages' => static::withTrashedQuery(...)]; + if ($deletable instanceof Book) { + $countsToLoad['chapters'] = static::withTrashedQuery(...); + } + $deletable->loadCount($countsToLoad); + } + } + + /** + * @param Builder $query + */ + protected static function withTrashedQuery(Builder $query): void + { + $query->withTrashed(); + } +} diff --git a/app/Entities/Controllers/RecycleBinController.php b/app/Entities/Controllers/RecycleBinController.php new file mode 100644 index 00000000000..f3c2b6a01cc --- /dev/null +++ b/app/Entities/Controllers/RecycleBinController.php @@ -0,0 +1,129 @@ +middleware(function ($request, $next) { + $this->checkPermission(Permission::SettingsManage); + $this->checkPermission(Permission::RestrictionsManageAll); + + return $next($request); + }); + } + + /** + * Show the top-level listing for the recycle bin. + */ + public function index() + { + $deletions = Deletion::query()->with(['deletable', 'deleter'])->paginate(10); + + $this->setPageTitle(trans('settings.recycle_bin')); + + return view('settings.recycle-bin.index', [ + 'deletions' => $deletions, + ]); + } + + /** + * Show the page to confirm a restore of the deletion of the given id. + */ + public function showRestore(string $id) + { + /** @var Deletion $deletion */ + $deletion = Deletion::query()->findOrFail($id); + + // Walk the parent chain to find any cascading parent deletions + $currentDeletable = $deletion->deletable; + $searching = true; + while ($searching && $currentDeletable instanceof Entity) { + $parent = $currentDeletable->getParent(); + if ($parent && $parent->trashed()) { + $currentDeletable = $parent; + } else { + $searching = false; + } + } + + /** @var ?Deletion $parentDeletion */ + $parentDeletion = ($currentDeletable === $deletion->deletable) ? null : $currentDeletable->deletions()->first(); + + return view('settings.recycle-bin.restore', [ + 'deletion' => $deletion, + 'parentDeletion' => $parentDeletion, + ]); + } + + /** + * Restore the element attached to the given deletion. + * + * @throws \Exception + */ + public function restore(DeletionRepo $deletionRepo, string $id) + { + $restoreCount = $deletionRepo->restore((int) $id); + + $this->showSuccessNotification(trans('settings.recycle_bin_restore_notification', ['count' => $restoreCount])); + + return redirect($this->recycleBinBaseUrl); + } + + /** + * Show the page to confirm a Permanent deletion of the element attached to the deletion of the given id. + */ + public function showDestroy(string $id) + { + /** @var Deletion $deletion */ + $deletion = Deletion::query()->findOrFail($id); + + return view('settings.recycle-bin.destroy', [ + 'deletion' => $deletion, + ]); + } + + /** + * Permanently delete the content associated with the given deletion. + * + * @throws \Exception + */ + public function destroy(DeletionRepo $deletionRepo, string $id) + { + $deleteCount = $deletionRepo->destroy((int) $id); + + $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount])); + + return redirect($this->recycleBinBaseUrl); + } + + /** + * Empty out the recycle bin. + * + * @throws \Exception + */ + public function empty(TrashCan $trash) + { + $deleteCount = $trash->empty(); + + $this->logActivity(ActivityType::RECYCLE_BIN_EMPTY); + $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount])); + + return redirect($this->recycleBinBaseUrl); + } +} diff --git a/app/Entities/EntityExistsRule.php b/app/Entities/EntityExistsRule.php new file mode 100644 index 00000000000..da210544611 --- /dev/null +++ b/app/Entities/EntityExistsRule.php @@ -0,0 +1,20 @@ +where('type', $this->type); + return $existsRule->__toString(); + } +} diff --git a/app/Entities/EntityProvider.php b/app/Entities/EntityProvider.php new file mode 100644 index 00000000000..3276a6c7a91 --- /dev/null +++ b/app/Entities/EntityProvider.php @@ -0,0 +1,80 @@ +bookshelf = new Bookshelf(); + $this->book = new Book(); + $this->chapter = new Chapter(); + $this->page = new Page(); + $this->pageRevision = new PageRevision(); + } + + /** + * Fetch all core entity types as an associated array + * with their basic names as the keys. + * + * @return array + */ + public function all(): array + { + return [ + 'bookshelf' => $this->bookshelf, + 'book' => $this->book, + 'chapter' => $this->chapter, + 'page' => $this->page, + ]; + } + + /** + * Get an entity instance by its basic name. + */ + public function get(string $type): Entity + { + $type = strtolower($type); + $instance = $this->all()[$type] ?? null; + + if (is_null($instance)) { + throw new \InvalidArgumentException("Provided type \"{$type}\" is not a valid entity type"); + } + + return $instance; + } + + /** + * Get the morph classes, as an array, for a single or multiple types. + */ + public function getMorphClasses(array $types): array + { + $morphClasses = []; + foreach ($types as $type) { + $model = $this->get($type); + $morphClasses[] = $model->getMorphClass(); + } + + return $morphClasses; + } +} diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php new file mode 100644 index 00000000000..10f04695a5e --- /dev/null +++ b/app/Entities/Models/Book.php @@ -0,0 +1,114 @@ +slug), trim($path, '/')])); + } + + /** + * Get all pages within this book. + * @return HasMany + */ + public function pages(): HasMany + { + return $this->hasMany(Page::class); + } + + /** + * Get the direct child pages of this book. + */ + public function directPages(): HasMany + { + return $this->pages()->whereNull('chapter_id'); + } + + /** + * Get all chapters within this book. + * @return HasMany + */ + public function chapters(): HasMany + { + return $this->hasMany(Chapter::class); + } + + /** + * Get the shelves this book is contained within. + */ + public function shelves(): BelongsToMany + { + return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id'); + } + + /** + * Get the direct child items within this book. + */ + public function getDirectVisibleChildren(): Collection + { + $pages = $this->directPages()->scopes('visible')->get(); + $chapters = $this->chapters()->scopes('visible')->get(); + + return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft'); + } + + public function defaultTemplate(): EntityDefaultTemplate + { + return new EntityDefaultTemplate($this); + } + + public function cover(): BelongsTo + { + return $this->belongsTo(Image::class, 'image_id'); + } + + public function coverInfo(): EntityCover + { + return new EntityCover($this); + } + + /** + * Get the sort rule assigned to this container, if existing. + */ + public function sortRule(): BelongsTo + { + return $this->belongsTo(SortRule::class); + } +} diff --git a/app/Entities/Models/BookChild.php b/app/Entities/Models/BookChild.php new file mode 100644 index 00000000000..9a8493c3a0a --- /dev/null +++ b/app/Entities/Models/BookChild.php @@ -0,0 +1,25 @@ + + */ + public function book(): BelongsTo + { + return $this->belongsTo(Book::class)->withTrashed(); + } +} diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php new file mode 100644 index 00000000000..320346512e1 --- /dev/null +++ b/app/Entities/Models/Bookshelf.php @@ -0,0 +1,83 @@ +belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id') + ->select(['entities.*', 'entity_container_data.*']) + ->withPivot('order') + ->orderBy('order', 'asc'); + } + + /** + * Related books that are visible to the current user. + */ + public function visibleBooks(): BelongsToMany + { + return $this->books()->scopes('visible'); + } + + /** + * Get the url for this bookshelf. + */ + public function getUrl(string $path = ''): string + { + return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')])); + } + + /** + * Check if this shelf contains the given book. + */ + public function contains(Book $book): bool + { + return $this->books()->where('id', '=', $book->id)->count() > 0; + } + + /** + * Add a book to the end of this shelf. + */ + public function appendBook(Book $book): void + { + if ($this->contains($book)) { + return; + } + + $maxOrder = $this->books()->max('order'); + $this->books()->attach($book->id, ['order' => $maxOrder + 1]); + } + + public function coverInfo(): EntityCover + { + return new EntityCover($this); + } + + public function cover(): BelongsTo + { + return $this->belongsTo(Image::class, 'image_id'); + } +} diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php new file mode 100644 index 00000000000..2dd4cb77f05 --- /dev/null +++ b/app/Entities/Models/Chapter.php @@ -0,0 +1,68 @@ + $pages + * @property ?int $default_template_id + * @property string $description + * @property string $description_html + */ +class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface +{ + use HasFactory; + use ContainerTrait; + + public float $searchFactor = 1.2; + protected $hidden = ['pivot', 'deleted_at', 'description_html', 'sort_rule_id', 'image_id', 'entity_id', 'entity_type', 'chapter_id']; + protected $fillable = ['name', 'priority']; + + /** + * Get the pages that this chapter contains. + * + * @return HasMany + */ + public function pages(string $dir = 'ASC'): HasMany + { + return $this->hasMany(Page::class)->orderBy('priority', $dir); + } + + /** + * Get the url of this chapter. + */ + public function getUrl(string $path = ''): string + { + $parts = [ + 'books', + urlencode($this->book_slug ?? $this->book->slug), + 'chapter', + urlencode($this->slug), + trim($path, '/'), + ]; + + return url('/' . implode('/', $parts)); + } + + /** + * Get the visible pages in this chapter. + * @return Collection + */ + public function getVisiblePages(): Collection + { + return $this->pages() + ->scopes('visible') + ->orderBy('draft', 'desc') + ->orderBy('priority', 'asc') + ->get(); + } + + public function defaultTemplate(): EntityDefaultTemplate + { + return new EntityDefaultTemplate($this); + } +} diff --git a/app/Entities/Models/ContainerTrait.php b/app/Entities/Models/ContainerTrait.php new file mode 100644 index 00000000000..9ef5ca8d43a --- /dev/null +++ b/app/Entities/Models/ContainerTrait.php @@ -0,0 +1,26 @@ + + */ + public function relatedData(): HasOne + { + return $this->hasOne(EntityContainerData::class, 'entity_id', 'id') + ->where('entity_type', '=', $this->getMorphClass()); + } +} diff --git a/app/Entities/Models/DeletableInterface.php b/app/Entities/Models/DeletableInterface.php new file mode 100644 index 00000000000..f771d9c6913 --- /dev/null +++ b/app/Entities/Models/DeletableInterface.php @@ -0,0 +1,14 @@ +morphTo('deletable')->withTrashed(); + } + + /** + * Get the user that performed the deletion. + */ + public function deleter(): BelongsTo + { + return $this->belongsTo(User::class, 'deleted_by'); + } + + /** + * Create a new deletion record for the provided entity. + */ + public static function createForEntity(Entity $entity): self + { + $record = (new self())->forceFill([ + 'deleted_by' => user()->id, + 'deletable_type' => $entity->getMorphClass(), + 'deletable_id' => $entity->id, + ]); + $record->save(); + + return $record; + } + + public function logDescriptor(): string + { + $deletable = $this->deletable()->first(); + + if ($deletable instanceof Entity) { + return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}"; + } + + return "Deletion ({$this->id})"; + } + + /** + * Get a URL for this specific deletion. + */ + public function getUrl(string $path = 'restore'): string + { + return url("/settings/recycle-bin/{$this->id}/" . ltrim($path, '/')); + } +} diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php new file mode 100644 index 00000000000..27cfccaa836 --- /dev/null +++ b/app/Entities/Models/Entity.php @@ -0,0 +1,485 @@ +relatedData()->firstOrNew(); + $contentFields = $this->getContentsAttributes(); + + foreach ($contentFields as $key => $value) { + $contents->setAttribute($key, $value); + unset($this->attributes[$key]); + } + + $this->setAttribute('type', $this->getMorphClass()); + $result = parent::save($options); + $contentsResult = true; + + if ($result && $contents->isDirty()) { + $contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()]; + $contents->forceFill($contentsFillData); + $contentsResult = $contents->save(); + $this->touch(); + } + + $this->forceFill($contentFields); + + return $result && $contentsResult; + } + + /** + * Check if this item is a container item. + */ + public function isContainer(): bool + { + return $this instanceof Bookshelf || + $this instanceof Book || + $this instanceof Chapter; + } + + /** + * Get the entities that are visible to the current user. + */ + public function scopeVisible(Builder $query): Builder + { + return app()->make(PermissionApplicator::class)->restrictEntityQuery($query); + } + + /** + * Query scope to get the last view from the current user. + */ + public function scopeWithLastView(Builder $query) + { + $viewedAtQuery = View::query()->select('updated_at') + ->whereColumn('viewable_id', '=', 'entities.id') + ->whereColumn('viewable_type', '=', 'entities.type') + ->where('user_id', '=', user()->id) + ->take(1); + + return $query->addSelect(['last_viewed_at' => $viewedAtQuery]); + } + + /** + * Query scope to get the total view count of the entities. + */ + public function scopeWithViewCount(Builder $query): void + { + $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count') + ->whereColumn('viewable_id', '=', 'entities.id') + ->whereColumn('viewable_type', '=', 'entities.type') + ->take(1); + + $query->addSelect(['view_count' => $viewCountQuery]); + } + + /** + * Compares this entity to another given entity. + * Matches by comparing class and id. + */ + public function matches(self $entity): bool + { + return [get_class($this), $this->id] === [get_class($entity), $entity->id]; + } + + /** + * Checks if the current entity matches or contains the given. + */ + public function matchesOrContains(self $entity): bool + { + if ($this->matches($entity)) { + return true; + } + + if (($entity instanceof BookChild) && $this instanceof Book) { + return $entity->book_id === $this->id; + } + + if ($entity instanceof Page && $this instanceof Chapter) { + return $entity->chapter_id === $this->id; + } + + return false; + } + + /** + * Gets the activity objects for this entity. + */ + public function activity(): MorphMany + { + return $this->morphMany(Activity::class, 'loggable') + ->orderBy('created_at', 'desc'); + } + + /** + * Get View objects for this entity. + */ + public function views(): MorphMany + { + return $this->morphMany(View::class, 'viewable'); + } + + /** + * Get the Tag models that have been user assigned to this entity. + */ + public function tags(): MorphMany + { + return $this->morphMany(Tag::class, 'entity') + ->orderBy('order', 'asc'); + } + + /** + * Get the comments for an entity. + * @return MorphMany + */ + public function comments(bool $orderByCreated = true): MorphMany + { + $query = $this->morphMany(Comment::class, 'commentable'); + + return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query; + } + + /** + * Get the related search terms. + */ + public function searchTerms(): MorphMany + { + return $this->morphMany(SearchTerm::class, 'entity'); + } + + /** + * Get this entities assigned permissions. + */ + public function permissions(): MorphMany + { + return $this->morphMany(EntityPermission::class, 'entity'); + } + + /** + * Check if this entity has a specific restriction set against it. + */ + public function hasPermissions(): bool + { + return $this->permissions()->count() > 0; + } + + /** + * Get the entity jointPermissions this is connected to. + */ + public function jointPermissions(): MorphMany + { + return $this->morphMany(JointPermission::class, 'entity'); + } + + /** + * Get the user who owns this entity. + * @return BelongsTo + */ + public function ownedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'owned_by'); + } + + public function getOwnerFieldName(): string + { + return 'owned_by'; + } + + /** + * Get the related delete records for this entity. + */ + public function deletions(): MorphMany + { + return $this->morphMany(Deletion::class, 'deletable'); + } + + /** + * Get the references pointing from this entity to other items. + */ + public function referencesFrom(): MorphMany + { + return $this->morphMany(Reference::class, 'from'); + } + + /** + * Get the references pointing to this entity from other items. + */ + public function referencesTo(): MorphMany + { + return $this->morphMany(Reference::class, 'to'); + } + + /** + * Check if this instance or class is a certain type of entity. + * Examples of $type are 'page', 'book', 'chapter'. + * + * @deprecated Use instanceof instead. + */ + public static function isA(string $type): bool + { + return static::getType() === strtolower($type); + } + + /** + * Get the entity type as a simple lowercase word. + */ + public static function getType(): string + { + $className = array_slice(explode('\\', static::class), -1, 1)[0]; + + return strtolower($className); + } + + /** + * Gets a limited-length version of the entity name. + */ + public function getShortName(int $length = 25): string + { + if (mb_strlen($this->name) <= $length) { + return $this->name; + } + + return mb_substr($this->name, 0, $length - 3) . '...'; + } + + /** + * Get an excerpt of this entity's descriptive content to the specified length. + */ + public function getExcerpt(int $length = 100): string + { + $text = $this->{$this->textField} ?? ''; + + if (mb_strlen($text) > $length) { + $text = mb_substr($text, 0, $length - 3) . '...'; + } + + return trim($text); + } + + /** + * Get the url of this entity. + */ + abstract public function getUrl(string $path = '/'): string; + + /** + * Get the parent entity if existing. + * This is the "static" parent and does not include dynamic + * relations such as shelves to books. + */ + public function getParent(): ?self + { + if ($this instanceof Page) { + /** @var BelongsTo $builder */ + $builder = $this->chapter_id ? $this->chapter() : $this->book(); + return $builder->withTrashed()->first(); + } + if ($this instanceof Chapter) { + /** @var BelongsTo $builder */ + $builder = $this->book(); + return $builder->withTrashed()->first(); + } + + return null; + } + + /** + * Rebuild the permissions for this entity. + */ + public function rebuildPermissions(): void + { + app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this); + } + + /** + * Index the current entity for search. + */ + public function indexForSearch(): void + { + app()->make(SearchIndex::class)->indexEntity(clone $this); + } + + /** + * {@inheritdoc} + */ + public function favourites(): MorphMany + { + return $this->morphMany(Favourite::class, 'favouritable'); + } + + /** + * Check if the entity is a favourite of the current user. + */ + public function isFavourite(): bool + { + return $this->favourites() + ->where('user_id', '=', user()->id) + ->exists(); + } + + /** + * Get the related watches for this entity. + */ + public function watches(): MorphMany + { + return $this->morphMany(Watch::class, 'watchable'); + } + + /** + * Get the related slug history for this entity. + */ + public function slugHistory(): MorphMany + { + return $this->morphMany(SlugHistory::class, 'sluggable'); + } + + /** + * {@inheritdoc} + */ + public function logDescriptor(): string + { + return "({$this->id}) {$this->name}"; + } + + /** + * @return HasOne + */ + abstract public function relatedData(): HasOne; + + /** + * Get the attributes that are intended for the related contents model. + * @return array + */ + protected function getContentsAttributes(): array + { + $contentFields = []; + $contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class; + + foreach ($this->attributes as $key => $value) { + if (in_array($key, $contentModel::$fields)) { + $contentFields[$key] = $value; + } + } + + return $contentFields; + } + + /** + * Create a new instance for the given entity type. + */ + public static function instanceFromType(string $type): self + { + return match ($type) { + 'page' => new Page(), + 'chapter' => new Chapter(), + 'book' => new Book(), + 'bookshelf' => new Bookshelf(), + default => throw new \InvalidArgumentException("Invalid entity type: {$type}"), + }; + } +} diff --git a/app/Entities/Models/EntityContainerData.php b/app/Entities/Models/EntityContainerData.php new file mode 100644 index 00000000000..21bace7513f --- /dev/null +++ b/app/Entities/Models/EntityContainerData.php @@ -0,0 +1,52 @@ +where($this->getKeyName(), '=', $this->getKeyForSaveQuery()) + ->where('entity_type', '=', $this->entity_type); + + return $query; + } + + /** + * Override the default set keys for a select query method to make it work with composite keys. + */ + protected function setKeysForSelectQuery($query): Builder + { + $query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery()) + ->where('entity_type', '=', $this->entity_type); + + return $query; + } +} diff --git a/app/Entities/Models/EntityPageData.php b/app/Entities/Models/EntityPageData.php new file mode 100644 index 00000000000..a98b1a9823c --- /dev/null +++ b/app/Entities/Models/EntityPageData.php @@ -0,0 +1,25 @@ +withGlobalScope('entity', new EntityScope()); + } + + public function withoutGlobalScope($scope): static + { + // Prevent removal of the entity scope + if ($scope === 'entity') { + return $this; + } + + return parent::withoutGlobalScope($scope); + } + + /** + * Override the default forceDelete method to add type filter onto the query + * since it specifically ignores scopes by default. + */ + public function forceDelete() + { + return $this->query->where('type', '=', $this->model->getMorphClass())->delete(); + } +} diff --git a/app/Entities/Models/EntityScope.php b/app/Entities/Models/EntityScope.php new file mode 100644 index 00000000000..c77b75a1624 --- /dev/null +++ b/app/Entities/Models/EntityScope.php @@ -0,0 +1,28 @@ +where('type', '=', $model->getMorphClass()); + $table = $model->getTable(); + if ($model instanceof Page) { + $builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', "{$table}.id"); + } else { + $builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model, $table) { + $join->on('entity_container_data.entity_id', '=', "{$table}.id") + ->where('entity_container_data.entity_type', '=', $model->getMorphClass()); + }); + } + } +} diff --git a/app/Entities/Models/EntityTable.php b/app/Entities/Models/EntityTable.php new file mode 100644 index 00000000000..5780162d1d2 --- /dev/null +++ b/app/Entities/Models/EntityTable.php @@ -0,0 +1,69 @@ +make(PermissionApplicator::class)->restrictEntityQuery($query); + } + + /** + * Get the entity jointPermissions this is connected to. + */ + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id') + ->whereColumn('entity_type', '=', 'entities.type'); + } + + /** + * Get the Tags that have been assigned to entities. + */ + public function tags(): HasMany + { + return $this->hasMany(Tag::class, 'entity_id') + ->whereColumn('entity_type', '=', 'entities.type'); + } + + /** + * Get the assigned permissions. + */ + public function permissions(): HasMany + { + return $this->hasMany(EntityPermission::class, 'entity_id') + ->whereColumn('entity_type', '=', 'entities.type'); + } + + /** + * Get View objects for this entity. + */ + public function views(): HasMany + { + return $this->hasMany(View::class, 'viewable_id') + ->whereColumn('viewable_type', '=', 'entities.type'); + } +} diff --git a/app/Entities/Models/HasCoverInterface.php b/app/Entities/Models/HasCoverInterface.php new file mode 100644 index 00000000000..a4e79e9004d --- /dev/null +++ b/app/Entities/Models/HasCoverInterface.php @@ -0,0 +1,18 @@ + + */ + public function cover(): BelongsTo; +} diff --git a/app/Entities/Models/HasDefaultTemplateInterface.php b/app/Entities/Models/HasDefaultTemplateInterface.php new file mode 100644 index 00000000000..f3af0da48ab --- /dev/null +++ b/app/Entities/Models/HasDefaultTemplateInterface.php @@ -0,0 +1,10 @@ + 'boolean', + 'template' => 'boolean', + ]; + + /** + * Get the entities that are visible to the current user. + */ + public function scopeVisible(Builder $query): Builder + { + $query = app()->make(PermissionApplicator::class)->restrictDraftsOnPageQuery($query); + + return parent::scopeVisible($query); + } + + /** + * Get the chapter that this page is in, If applicable. + */ + public function chapter(): BelongsTo + { + return $this->belongsTo(Chapter::class); + } + + /** + * Check if this page has a chapter. + */ + public function hasChapter(): bool + { + return $this->chapter()->count() > 0; + } + + /** + * Get the associated page revisions, ordered by created date. + * Only provides actual saved page revision instances, Not drafts. + */ + public function revisions(): HasMany + { + return $this->allRevisions() + ->where('type', '=', 'version') + ->orderBy('created_at', 'desc') + ->orderBy('id', 'desc'); + } + + /** + * Get the current revision for the page if existing. + */ + public function currentRevision(): HasOne + { + return $this->hasOne(PageRevision::class) + ->where('type', '=', 'version') + ->orderBy('created_at', 'desc') + ->orderBy('id', 'desc'); + } + + /** + * Get all revision instances assigned to this page. + * Includes all types of revisions. + */ + public function allRevisions(): HasMany + { + return $this->hasMany(PageRevision::class); + } + + /** + * Get the attachments assigned to this page. + */ + public function attachments(): HasMany + { + return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc'); + } + + /** + * Get the url of this page. + */ + public function getUrl(string $path = ''): string + { + $parts = [ + 'books', + urlencode($this->book_slug ?? $this->book->slug), + $this->draft ? 'draft' : 'page', + $this->draft ? $this->id : urlencode($this->slug), + trim($path, '/'), + ]; + + return url('/' . implode('/', $parts)); + } + + /** + * Get the ID-based permalink for this page. + */ + public function getPermalink(): string + { + return url("/link/{$this->id}"); + } + + /** + * Get this page for JSON display. + */ + public function forJsonDisplay(): self + { + $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']); + $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown'])); + $refreshed->setAttribute('raw_html', $refreshed->html); + $refreshed->setAttribute('html', (new PageContent($refreshed))->render()); + + return $refreshed; + } + + /** + * @return HasOne + */ + public function relatedData(): HasOne + { + return $this->hasOne(EntityPageData::class, 'page_id', 'id'); + } +} diff --git a/app/Entities/Models/PageRevision.php b/app/Entities/Models/PageRevision.php new file mode 100644 index 00000000000..4409afdc222 --- /dev/null +++ b/app/Entities/Models/PageRevision.php @@ -0,0 +1,95 @@ +belongsTo(User::class, 'created_by'); + } + + /** + * Get the page this revision originates from. + */ + public function page(): BelongsTo + { + return $this->belongsTo(Page::class); + } + + /** + * Get the url for this revision. + */ + public function getUrl(string $path = ''): string + { + return $this->page->getUrl('/revisions/' . $this->id . '/' . ltrim($path, '/')); + } + + /** + * Get the previous revision for the same page if existing. + */ + public function getPreviousRevision(): ?PageRevision + { + $id = static::newQuery()->where('page_id', '=', $this->page_id) + ->where('id', '<', $this->id) + ->max('id'); + + if ($id) { + return static::query()->find($id); + } + + return null; + } + + /** + * Allows checking of the exact class, Used to check entity type. + * Included here to align with entities in similar use cases. + * (Yup, Bit of an awkward hack). + * + * @deprecated Use instanceof instead. + */ + public static function isA(string $type): bool + { + return $type === 'revision'; + } + + public function logDescriptor(): string + { + return "Revision #{$this->revision_number} (ID: {$this->id}) for page ID {$this->page_id}"; + } +} diff --git a/app/Entities/Models/SlugHistory.php b/app/Entities/Models/SlugHistory.php new file mode 100644 index 00000000000..4041cedd959 --- /dev/null +++ b/app/Entities/Models/SlugHistory.php @@ -0,0 +1,28 @@ +hasMany(JointPermission::class, 'entity_id', 'sluggable_id') + ->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type'); + } +} diff --git a/app/Entities/Queries/BookQueries.php b/app/Entities/Queries/BookQueries.php new file mode 100644 index 00000000000..a466f37bc0f --- /dev/null +++ b/app/Entities/Queries/BookQueries.php @@ -0,0 +1,83 @@ + + */ +class BookQueries implements ProvidesEntityQueries +{ + protected static array $listAttributes = [ + 'id', 'slug', 'name', 'description', + 'created_at', 'updated_at', 'image_id', 'owned_by', + ]; + + /** + * @return Builder + */ + public function start(): Builder + { + return Book::query(); + } + + public function findVisibleById(int $id): ?Book + { + return $this->start()->scopes('visible')->find($id); + } + + public function findVisibleByIdOrFail(int $id): Book + { + return $this->start()->scopes('visible')->findOrFail($id); + } + + public function findVisibleBySlugOrFail(string $slug): Book + { + /** @var ?Book $book */ + $book = $this->start() + ->scopes('visible') + ->where('slug', '=', $slug) + ->first(); + + if ($book === null) { + throw new NotFoundException(trans('errors.book_not_found')); + } + + return $book; + } + + public function visibleForList(): Builder + { + return $this->start()->scopes('visible') + ->select(static::$listAttributes); + } + + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } + + public function visibleForListWithCover(): Builder + { + return $this->visibleForList()->with('cover'); + } + + public function recentlyViewedForCurrentUser(): Builder + { + return $this->visibleForList() + ->scopes('withLastView') + ->having('last_viewed_at', '>', 0) + ->orderBy('last_viewed_at', 'desc'); + } + + public function popularForList(): Builder + { + return $this->visibleForList() + ->scopes('withViewCount') + ->having('view_count', '>', 0) + ->orderBy('view_count', 'desc'); + } +} diff --git a/app/Entities/Queries/BookshelfQueries.php b/app/Entities/Queries/BookshelfQueries.php new file mode 100644 index 00000000000..3fe0a2afcef --- /dev/null +++ b/app/Entities/Queries/BookshelfQueries.php @@ -0,0 +1,88 @@ + + */ +class BookshelfQueries implements ProvidesEntityQueries +{ + protected static array $listAttributes = [ + 'id', 'slug', 'name', 'description', + 'created_at', 'updated_at', 'image_id', 'owned_by', + ]; + + /** + * @return Builder + */ + public function start(): Builder + { + return Bookshelf::query(); + } + + public function findVisibleById(int $id): ?Bookshelf + { + return $this->start()->scopes('visible')->find($id); + } + + public function findVisibleByIdOrFail(int $id): Bookshelf + { + $shelf = $this->findVisibleById($id); + + if (is_null($shelf)) { + throw new NotFoundException(trans('errors.bookshelf_not_found')); + } + + return $shelf; + } + + public function findVisibleBySlugOrFail(string $slug): Bookshelf + { + /** @var ?Bookshelf $shelf */ + $shelf = $this->start() + ->scopes('visible') + ->where('slug', '=', $slug) + ->first(); + + if ($shelf === null) { + throw new NotFoundException(trans('errors.bookshelf_not_found')); + } + + return $shelf; + } + + public function visibleForList(): Builder + { + return $this->start()->scopes('visible')->select(static::$listAttributes); + } + + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } + + public function visibleForListWithCover(): Builder + { + return $this->visibleForList()->with('cover'); + } + + public function recentlyViewedForCurrentUser(): Builder + { + return $this->visibleForList() + ->scopes('withLastView') + ->having('last_viewed_at', '>', 0) + ->orderBy('last_viewed_at', 'desc'); + } + + public function popularForList(): Builder + { + return $this->visibleForList() + ->scopes('withViewCount') + ->having('view_count', '>', 0) + ->orderBy('view_count', 'desc'); + } +} diff --git a/app/Entities/Queries/ChapterQueries.php b/app/Entities/Queries/ChapterQueries.php new file mode 100644 index 00000000000..9ddeb9b5896 --- /dev/null +++ b/app/Entities/Queries/ChapterQueries.php @@ -0,0 +1,78 @@ + + */ +class ChapterQueries implements ProvidesEntityQueries +{ + protected static array $listAttributes = [ + 'id', 'slug', 'name', 'description', 'priority', + 'book_id', 'created_at', 'updated_at', 'owned_by', + ]; + + public function start(): Builder + { + return Chapter::query(); + } + + public function findVisibleById(int $id): ?Chapter + { + return $this->start()->scopes('visible')->find($id); + } + + public function findVisibleByIdOrFail(int $id): Chapter + { + return $this->start()->scopes('visible')->findOrFail($id); + } + + public function findVisibleBySlugsOrFail(string $bookSlug, string $chapterSlug): Chapter + { + /** @var ?Chapter $chapter */ + $chapter = $this->start() + ->scopes('visible') + ->with('book') + ->whereHas('book', function (Builder $query) use ($bookSlug) { + $query->where('slug', '=', $bookSlug); + }) + ->where('slug', '=', $chapterSlug) + ->first(); + + if (is_null($chapter)) { + throw new NotFoundException(trans('errors.chapter_not_found')); + } + + return $chapter; + } + + public function usingSlugs(string $bookSlug, string $chapterSlug): Builder + { + return $this->start() + ->where('slug', '=', $chapterSlug) + ->whereHas('book', function (Builder $query) use ($bookSlug) { + $query->where('slug', '=', $bookSlug); + }); + } + + public function visibleForList(): Builder + { + return $this->start() + ->scopes('visible') + ->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) { + $builder->select('slug') + ->from('entities as books') + ->where('type', '=', 'book') + ->whereColumn('books.id', '=', 'entities.book_id'); + }])); + } + + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } +} diff --git a/app/Entities/Queries/EntityQueries.php b/app/Entities/Queries/EntityQueries.php new file mode 100644 index 00000000000..3ffa0adf3db --- /dev/null +++ b/app/Entities/Queries/EntityQueries.php @@ -0,0 +1,125 @@ +findVisibleById($entityType, $entityId); + } + + /** + * Find an entity by its ID. + */ + public function findVisibleById(string $type, int $id): ?Entity + { + $queries = $this->getQueriesForType($type); + return $queries->findVisibleById($id); + } + + /** + * Find an entity by looking up old slugs in the slug history. + */ + public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity + { + $id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug); + if ($id === null) { + return null; + } + + return $this->findVisibleById($type, $id); + } + + /** + * Start a query across all entity types. + * Combines the description/text fields into a single 'description' field. + * @return Builder + */ + public function visibleForList(): Builder + { + $rawDescriptionField = DB::raw('COALESCE(description, text) as description'); + $bookSlugSelect = function (QueryBuilder $query) { + return $query->select('slug')->from('entities as books') + ->whereColumn('books.id', '=', 'entities.book_id') + ->where('type', '=', 'book'); + }; + + return EntityTable::query()->scopes('visible') + ->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', 'book_slug' => $bookSlugSelect, $rawDescriptionField]) + ->leftJoin('entity_container_data', function (JoinClause $join) { + $join->on('entity_container_data.entity_id', '=', 'entities.id') + ->on('entity_container_data.entity_type', '=', 'entities.type'); + })->leftJoin('entity_page_data', function (JoinClause $join) { + $join->on('entity_page_data.page_id', '=', 'entities.id') + ->where('entities.type', '=', 'page'); + }); + } + + /** + * Start a query of visible entities of the given type, + * suitable for listing display. + * @return Builder + */ + public function visibleForListForType(string $entityType): Builder + { + $queries = $this->getQueriesForType($entityType); + return $queries->visibleForList(); + } + + /** + * Start a query of visible entities of the given type, + * suitable for using the contents of the items. + * @return Builder + */ + public function visibleForContentForType(string $entityType): Builder + { + $queries = $this->getQueriesForType($entityType); + return $queries->visibleForContent(); + } + + protected function getQueriesForType(string $type): ProvidesEntityQueries + { + $queries = match ($type) { + 'page' => $this->pages, + 'chapter' => $this->chapters, + 'book' => $this->books, + 'bookshelf' => $this->shelves, + default => null, + }; + + if (is_null($queries)) { + throw new InvalidArgumentException("No entity query class configured for {$type}"); + } + + return $queries; + } +} diff --git a/app/Entities/Queries/PageQueries.php b/app/Entities/Queries/PageQueries.php new file mode 100644 index 00000000000..f4ecee2dc08 --- /dev/null +++ b/app/Entities/Queries/PageQueries.php @@ -0,0 +1,130 @@ + + */ +class PageQueries implements ProvidesEntityQueries +{ + protected static array $contentAttributes = [ + 'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', + 'template', 'html', 'markdown', 'text', 'created_at', 'updated_at', 'priority', + 'created_by', 'updated_by', 'owned_by', + ]; + protected static array $listAttributes = [ + 'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', + 'template', 'text', 'created_at', 'updated_at', 'priority', 'owned_by', + ]; + + /** + * @return Builder + */ + public function start(): Builder + { + return Page::query(); + } + + public function findVisibleById(int $id): ?Page + { + return $this->start()->scopes('visible')->find($id); + } + + public function findVisibleByIdOrFail(int $id): Page + { + $page = $this->findVisibleById($id); + + if (is_null($page)) { + throw new NotFoundException(trans('errors.page_not_found')); + } + + return $page; + } + + public function findVisibleBySlugsOrFail(string $bookSlug, string $pageSlug): Page + { + /** @var ?Page $page */ + $page = $this->start()->with('book') + ->scopes('visible') + ->whereHas('book', function (Builder $query) use ($bookSlug) { + $query->where('slug', '=', $bookSlug); + }) + ->where('slug', '=', $pageSlug) + ->first(); + + if (is_null($page)) { + throw new NotFoundException(trans('errors.page_not_found')); + } + + return $page; + } + + public function usingSlugs(string $bookSlug, string $pageSlug): Builder + { + return $this->start() + ->where('slug', '=', $pageSlug) + ->whereHas('book', function (Builder $query) use ($bookSlug) { + $query->where('slug', '=', $bookSlug); + }); + } + + /** + * @return Builder + */ + public function visibleForList(): Builder + { + return $this->start() + ->scopes('visible') + ->select($this->mergeBookSlugForSelect(static::$listAttributes)); + } + + /** + * @return Builder + */ + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } + + public function visibleForChapterList(int $chapterId): Builder + { + return $this->visibleForList() + ->where('chapter_id', '=', $chapterId) + ->orderBy('draft', 'desc') + ->orderBy('priority', 'asc'); + } + + public function visibleWithContents(): Builder + { + return $this->start() + ->scopes('visible') + ->select($this->mergeBookSlugForSelect(static::$contentAttributes)); + } + + public function currentUserDraftsForList(): Builder + { + return $this->visibleForList() + ->where('draft', '=', true) + ->where('created_by', '=', user()->id); + } + + public function visibleTemplates(bool $includeContents = false): Builder + { + $base = $includeContents ? $this->visibleWithContents() : $this->visibleForList(); + return $base->where('template', '=', true); + } + + protected function mergeBookSlugForSelect(array $columns): array + { + return array_merge($columns, ['book_slug' => function ($builder) { + $builder->select('slug') + ->from('entities as books') + ->where('type', '=', 'book') + ->whereColumn('books.id', '=', 'entities.book_id'); + }]); + } +} diff --git a/app/Entities/Queries/PageRevisionQueries.php b/app/Entities/Queries/PageRevisionQueries.php new file mode 100644 index 00000000000..6e017a742dc --- /dev/null +++ b/app/Entities/Queries/PageRevisionQueries.php @@ -0,0 +1,44 @@ +whereHas('page', function (Builder $query) { + $query->scopes('visible'); + }) + ->where('slug', '=', $pageSlug) + ->where('type', '=', 'version') + ->where('book_slug', '=', $bookSlug) + ->orderBy('created_at', 'desc') + ->first(); + } + + public function findLatestCurrentUserDraftsForPageId(int $pageId): ?PageRevision + { + /** @var ?PageRevision $revision */ + $revision = $this->latestCurrentUserDraftsForPageId($pageId)->first(); + + return $revision; + } + + public function latestCurrentUserDraftsForPageId(int $pageId): Builder + { + return $this->start() + ->where('created_by', '=', user()->id) + ->where('type', 'update_draft') + ->where('page_id', '=', $pageId) + ->orderBy('created_at', 'desc'); + } +} diff --git a/app/Entities/Queries/ProvidesEntityQueries.php b/app/Entities/Queries/ProvidesEntityQueries.php new file mode 100644 index 00000000000..674e96afa24 --- /dev/null +++ b/app/Entities/Queries/ProvidesEntityQueries.php @@ -0,0 +1,45 @@ + + */ + public function start(): Builder; + + /** + * Find the entity of the given ID or return null if not found. + */ + public function findVisibleById(int $id): ?Entity; + + /** + * Start a query for items that are visible, with selection + * configured for list display of this item. + * @return Builder + */ + public function visibleForList(): Builder; + + /** + * Start a query for items that are visible, with selection + * configured for using the content of the items found. + * @return Builder + */ + public function visibleForContent(): Builder; +} diff --git a/app/Entities/Queries/QueryPopular.php b/app/Entities/Queries/QueryPopular.php new file mode 100644 index 00000000000..065ae82ef82 --- /dev/null +++ b/app/Entities/Queries/QueryPopular.php @@ -0,0 +1,42 @@ +permissions + ->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type') + ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count')) + ->groupBy('viewable_id', 'viewable_type') + ->orderBy('view_count', 'desc'); + + if (!empty($filterModels)) { + $query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels)); + } + + $views = $query + ->skip($count * ($page - 1)) + ->take($count) + ->get(); + + $this->listLoader->loadIntoRelations($views->all(), 'viewable', true); + + return $views->pluck('viewable')->filter(); + } +} diff --git a/app/Entities/Queries/QueryRecentlyViewed.php b/app/Entities/Queries/QueryRecentlyViewed.php new file mode 100644 index 00000000000..f28b8f8652f --- /dev/null +++ b/app/Entities/Queries/QueryRecentlyViewed.php @@ -0,0 +1,43 @@ +isGuest()) { + return collect(); + } + + $query = $this->permissions->restrictEntityRelationQuery( + View::query(), + 'views', + 'viewable_id', + 'viewable_type' + ) + ->orderBy('views.updated_at', 'desc') + ->where('user_id', '=', user()->id); + + $views = $query + ->skip(($page - 1) * $count) + ->take($count) + ->get(); + + $this->listLoader->loadIntoRelations($views->all(), 'viewable', false); + + return $views->pluck('viewable')->filter(); + } +} diff --git a/app/Entities/Queries/QueryTopFavourites.php b/app/Entities/Queries/QueryTopFavourites.php new file mode 100644 index 00000000000..6340e35ef18 --- /dev/null +++ b/app/Entities/Queries/QueryTopFavourites.php @@ -0,0 +1,45 @@ +isGuest()) { + return collect(); + } + + $query = $this->permissions + ->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type') + ->select('favourites.*') + ->leftJoin('views', function (JoinClause $join) { + $join->on('favourites.favouritable_id', '=', 'views.viewable_id'); + $join->on('favourites.favouritable_type', '=', 'views.viewable_type'); + $join->where('views.user_id', '=', user()->id); + }) + ->orderBy('views.views', 'desc') + ->where('favourites.user_id', '=', user()->id); + + $favourites = $query + ->skip($skip) + ->take($count) + ->get(); + + $this->listLoader->loadIntoRelations($favourites->all(), 'favouritable', false); + + return $favourites->pluck('favouritable')->filter(); + } +} diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php new file mode 100644 index 00000000000..44baeaccfdc --- /dev/null +++ b/app/Entities/Repos/BaseRepo.php @@ -0,0 +1,173 @@ +refresh(); + $entity->fill($input); + $entity->forceFill([ + 'created_by' => user()->id, + 'updated_by' => user()->id, + 'owned_by' => user()->id, + ]); + $this->refreshSlug($entity); + + if ($entity instanceof HasDescriptionInterface) { + $this->updateDescription($entity, $input); + } + + $entity->save(); + + if (isset($input['tags'])) { + $this->tagRepo->saveTagsToEntity($entity, $input['tags']); + } + + $entity->refresh(); + $entity->rebuildPermissions(); + $entity->indexForSearch(); + + $this->referenceStore->updateForEntity($entity); + + return $entity; + } + + /** + * Update the given entity. + * @template T of Entity + * @param T $entity + * @return T + */ + public function update(Entity $entity, array $input): Entity + { + $oldUrl = $entity->getUrl(); + + $entity->fill($input); + $entity->updated_by = user()->id; + + if ($entity->isDirty('name') || empty($entity->slug)) { + $this->refreshSlug($entity); + } + + if ($entity instanceof HasDescriptionInterface) { + $this->updateDescription($entity, $input); + } + + $entity->save(); + + if (isset($input['tags'])) { + $this->tagRepo->saveTagsToEntity($entity, $input['tags']); + $entity->touch(); + } + + $entity->indexForSearch(); + $this->referenceStore->updateForEntity($entity); + + if ($oldUrl !== $entity->getUrl()) { + $this->referenceUpdater->updateEntityReferences($entity, $oldUrl); + } + + return $entity; + } + + /** + * Update the given items' cover image or clear it. + * + * @throws ImageUploadException + * @throws \Exception + */ + public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void + { + if ($coverImage) { + $imageType = 'cover_' . $entity->type; + $this->imageRepo->destroyImage($entity->coverInfo()->getImage()); + $image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true); + $entity->coverInfo()->setImage($image); + $entity->save(); + } + + if ($removeImage) { + $this->imageRepo->destroyImage($entity->coverInfo()->getImage()); + $entity->coverInfo()->setImage(null); + $entity->save(); + } + } + + /** + * Sort the parent of the given entity if any auto sort actions are set for it. + * Typically ran during create/update/insert events. + */ + public function sortParent(Entity $entity): void + { + if ($entity instanceof BookChild) { + $book = $entity->book; + $this->bookSorter->runBookAutoSort($book); + } + } + + /** + * Update the description of the given entity from input data. + */ + protected function updateDescription(Entity $entity, array $input): void + { + if (!$entity instanceof HasDescriptionInterface) { + return; + } + + if (isset($input['description_html'])) { + $plainTextConverter = new HtmlToPlainText(); + $entity->descriptionInfo()->set( + HtmlDescriptionFilter::filterFromString($input['description_html']), + $plainTextConverter->convert($input['description_html']), + ); + } else if (isset($input['description'])) { + $entity->descriptionInfo()->set('', $input['description']); + } + } + + /** + * Refresh the slug for the given entity. + */ + public function refreshSlug(Entity $entity): void + { + $this->slugHistory->recordForEntity($entity); + $this->slugGenerator->regenerateForEntity($entity); + } +} diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php new file mode 100644 index 00000000000..b4244b9bb77 --- /dev/null +++ b/app/Entities/Repos/BookRepo.php @@ -0,0 +1,93 @@ +baseRepo->create(new Book(), $input); + $this->baseRepo->updateCoverImage($book, $input['image'] ?? null); + $book->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null)); + Activity::add(ActivityType::BOOK_CREATE, $book); + + $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); + if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) { + $book->sort_rule_id = $defaultBookSortSetting; + } + + $book->save(); + + return $book; + }))->run(); + } + + /** + * Update the given book. + */ + public function update(Book $book, array $input): Book + { + $book = $this->baseRepo->update($book, $input); + + if (array_key_exists('default_template_id', $input)) { + $book->defaultTemplate()->setFromId(intval($input['default_template_id'])); + } + + if (array_key_exists('image', $input)) { + $this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null); + } + + $book->save(); + Activity::add(ActivityType::BOOK_UPDATE, $book); + + return $book; + } + + /** + * Update the given book's cover image or clear it. + * + * @throws ImageUploadException + * @throws Exception + */ + public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false): void + { + $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage); + } + + /** + * Remove a book from the system. + * + * @throws Exception + */ + public function destroy(Book $book): void + { + $this->trashCan->softDestroyBook($book); + Activity::add(ActivityType::BOOK_DELETE, $book); + + $this->trashCan->autoClearOld(); + } +} diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php new file mode 100644 index 00000000000..bb84b51fd5e --- /dev/null +++ b/app/Entities/Repos/BookshelfRepo.php @@ -0,0 +1,104 @@ +baseRepo->create(new Bookshelf(), $input); + $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null); + $this->updateBooks($shelf, $bookIds); + Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf); + return $shelf; + }))->run(); + } + + /** + * Update an existing shelf in the system using the given input. + */ + public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf + { + $shelf = $this->baseRepo->update($shelf, $input); + + if (!is_null($bookIds)) { + $this->updateBooks($shelf, $bookIds); + } + + if (array_key_exists('image', $input)) { + $this->baseRepo->updateCoverImage($shelf, $input['image'], $input['image'] === null); + } + + Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf); + + return $shelf; + } + + /** + * Update which books are assigned to this shelf by syncing the given book ids. + * Function ensures the managed books are visible to the current user and existing, + * and that the user does not alter the assignment of books that are not visible to them. + */ + protected function updateBooks(Bookshelf $shelf, array $bookIds): void + { + $numericIDs = collect($bookIds)->map(function ($id) { + return intval($id); + }); + + $existingBookIds = $shelf->books()->pluck('id')->toArray(); + $visibleExistingBookIds = $this->bookQueries->visibleForList() + ->whereIn('id', $existingBookIds) + ->pluck('id') + ->toArray(); + $nonVisibleExistingBookIds = array_values(array_diff($existingBookIds, $visibleExistingBookIds)); + + $newIdsToAssign = $this->bookQueries->visibleForList() + ->whereIn('id', $bookIds) + ->pluck('id') + ->toArray(); + + $maxNewIndex = max($numericIDs->keys()->toArray() ?: [0]); + + $syncData = []; + foreach ($newIdsToAssign as $id) { + $syncData[$id] = ['order' => $numericIDs->search($id)]; + } + + foreach ($nonVisibleExistingBookIds as $index => $id) { + $syncData[$id] = ['order' => $maxNewIndex + ($index + 1)]; + } + + $shelf->books()->sync($syncData); + } + + /** + * Remove a bookshelf from the system. + * + * @throws Exception + */ + public function destroy(Bookshelf $shelf): void + { + $this->trashCan->softDestroyShelf($shelf); + Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf); + $this->trashCan->autoClearOld(); + } +} diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php new file mode 100644 index 00000000000..a528eece092 --- /dev/null +++ b/app/Entities/Repos/ChapterRepo.php @@ -0,0 +1,111 @@ +book_id = $parentBook->id; + $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1; + + $chapter = $this->baseRepo->create($chapter, $input); + $chapter->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null)); + + $chapter->save(); + Activity::add(ActivityType::CHAPTER_CREATE, $chapter); + + $this->baseRepo->sortParent($chapter); + + return $chapter; + }))->run(); + } + + /** + * Update the given chapter. + */ + public function update(Chapter $chapter, array $input): Chapter + { + $chapter = $this->baseRepo->update($chapter, $input); + + if (array_key_exists('default_template_id', $input)) { + $chapter->defaultTemplate()->setFromId(intval($input['default_template_id'])); + } + + $chapter->save(); + Activity::add(ActivityType::CHAPTER_UPDATE, $chapter); + + $this->baseRepo->sortParent($chapter); + + return $chapter; + } + + /** + * Remove a chapter from the system. + * + * @throws Exception + */ + public function destroy(Chapter $chapter): void + { + $this->trashCan->softDestroyChapter($chapter); + Activity::add(ActivityType::CHAPTER_DELETE, $chapter); + $this->trashCan->autoClearOld(); + } + + /** + * Move the given chapter into a new parent book. + * The $parentIdentifier must be a string of the following format: + * 'book:' (book:5). + * + * @throws MoveOperationException + * @throws PermissionsException + */ + public function move(Chapter $chapter, string $parentIdentifier): Book + { + $parent = $this->entityQueries->findVisibleByStringIdentifier($parentIdentifier); + if (!$parent instanceof Book) { + throw new MoveOperationException('Book to move chapter into not found'); + } + + if (!userCan(Permission::ChapterCreate, $parent)) { + throw new PermissionsException('User does not have permission to create a chapter within the chosen book'); + } + + return (new DatabaseTransaction(function () use ($chapter, $parent) { + $this->parentChanger->changeBook($chapter, $parent->id); + $chapter->rebuildPermissions(); + Activity::add(ActivityType::CHAPTER_MOVE, $chapter); + + $this->baseRepo->sortParent($chapter); + + return $parent; + }))->run(); + } +} diff --git a/app/Entities/Repos/DeletionRepo.php b/app/Entities/Repos/DeletionRepo.php new file mode 100644 index 00000000000..5b67e5e6b52 --- /dev/null +++ b/app/Entities/Repos/DeletionRepo.php @@ -0,0 +1,34 @@ +findOrFail($id); + Activity::add(ActivityType::RECYCLE_BIN_RESTORE, $deletion); + + return $this->trashCan->restoreFromDeletion($deletion); + } + + public function destroy(int $id): int + { + /** @var Deletion $deletion */ + $deletion = Deletion::query()->findOrFail($id); + Activity::add(ActivityType::RECYCLE_BIN_DESTROY, $deletion); + + return $this->trashCan->destroyFromDeletion($deletion); + } +} diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php new file mode 100644 index 00000000000..375bf1d2bc1 --- /dev/null +++ b/app/Entities/Repos/PageRepo.php @@ -0,0 +1,315 @@ +forceFill([ + 'name' => trans('entities.pages_initial_name'), + 'created_by' => user()->id, + 'owned_by' => user()->id, + 'updated_by' => user()->id, + 'draft' => true, + 'editor' => PageEditorType::getSystemDefault()->value, + 'html' => '', + 'markdown' => '', + 'text' => '', + ]); + + if ($parent instanceof Chapter) { + $page->chapter_id = $parent->id; + $page->book_id = $parent->book_id; + } else { + $page->book_id = $parent->id; + } + + $defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book->defaultTemplate()->get(); + if ($defaultTemplate) { + $page->forceFill([ + 'html' => $defaultTemplate->html, + 'markdown' => $defaultTemplate->markdown, + ]); + $page->text = (new PageContent($page))->toPlainText(); + } + + (new DatabaseTransaction(function () use ($page) { + $page->save(); + $page->rebuildPermissions(); + }))->run(); + + return $page; + } + + /** + * Publish a draft page to make it a live, non-draft page. + */ + public function publishDraft(Page $draft, array $input): Page + { + return (new DatabaseTransaction(function () use ($draft, $input) { + $draft->draft = false; + $draft->revision_count = 1; + $draft->priority = $this->getNewPriority($draft); + $this->updateTemplateStatusAndContentFromInput($draft, $input); + + $draft = $this->baseRepo->update($draft, $input); + $draft->rebuildPermissions(); + + $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision'); + $this->revisionRepo->storeNewForPage($draft, $summary); + $draft->refresh(); + + Activity::add(ActivityType::PAGE_CREATE, $draft); + $this->baseRepo->sortParent($draft); + + return $draft; + }))->run(); + } + + /** + * Directly update the content for the given page from the provided input. + * Used for direct content access in a way that performs required changes + * (Search index and reference regen) without performing an official update. + */ + public function setContentFromInput(Page $page, array $input): void + { + $this->updateTemplateStatusAndContentFromInput($page, $input); + $this->baseRepo->update($page, []); + } + + /** + * Update a page in the system. + */ + public function update(Page $page, array $input): Page + { + // Hold the old details to compare later + $oldName = $page->name; + $oldHtml = $page->html; + $oldMarkdown = $page->markdown; + + $this->updateTemplateStatusAndContentFromInput($page, $input); + $page = $this->baseRepo->update($page, $input); + + // Update with new details + $page->revision_count++; + $page->save(); + + // Remove all update drafts for this user and page. + $this->revisionRepo->deleteDraftsForCurrentUser($page); + + // Save a revision after updating + $summary = trim($input['summary'] ?? ''); + $htmlChanged = isset($input['html']) && $input['html'] !== $oldHtml; + $nameChanged = isset($input['name']) && $input['name'] !== $oldName; + $markdownChanged = isset($input['markdown']) && $input['markdown'] !== $oldMarkdown; + if ($htmlChanged || $nameChanged || $markdownChanged || $summary) { + $this->revisionRepo->storeNewForPage($page, $summary); + } + + Activity::add(ActivityType::PAGE_UPDATE, $page); + $this->baseRepo->sortParent($page); + + return $page; + } + + protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void + { + if (isset($input['template']) && userCan(Permission::TemplatesManage)) { + $page->template = ($input['template'] === 'true'); + } + + $pageContent = new PageContent($page); + $defaultEditor = PageEditorType::getSystemDefault(); + $currentEditor = PageEditorType::forPage($page) ?: $defaultEditor; + $inputEditor = PageEditorType::fromRequestValue($input['editor'] ?? '') ?? $currentEditor; + $newEditor = $currentEditor; + + $haveInput = isset($input['markdown']) || isset($input['html']); + $inputEmpty = empty($input['markdown']) && empty($input['html']); + + if ($haveInput && $inputEmpty) { + $pageContent->setNewHTML('', user()); + } elseif (!empty($input['markdown']) && is_string($input['markdown'])) { + $newEditor = PageEditorType::Markdown; + $pageContent->setNewMarkdown($input['markdown'], user()); + } elseif (isset($input['html'])) { + $newEditor = ($inputEditor->isHtmlBased() ? $inputEditor : null) ?? ($defaultEditor->isHtmlBased() ? $defaultEditor : null) ?? PageEditorType::WysiwygTinymce; + $pageContent->setNewHTML($input['html'], user()); + } + + if (($newEditor !== $currentEditor || empty($page->editor)) && userCan(Permission::EditorChange)) { + $page->editor = $newEditor->value; + } elseif (empty($page->editor)) { + $page->editor = $defaultEditor->value; + } + } + + /** + * Save a page update draft. + */ + public function updatePageDraft(Page $page, array $input): Page|PageRevision + { + // If the page itself is a draft, simply update that + if ($page->draft) { + $this->updateTemplateStatusAndContentFromInput($page, $input); + $page->forceFill(array_intersect_key($input, array_flip(['name'])))->save(); + $page->save(); + + return $page; + } + + // Otherwise, save the data to a revision + $draft = $this->revisionRepo->getNewDraftForCurrentUser($page); + $draft->fill($input); + + if (!empty($input['markdown'])) { + $draft->markdown = $input['markdown']; + $draft->html = ''; + } else { + $draft->html = $input['html']; + $draft->markdown = ''; + } + + $draft->save(); + + return $draft; + } + + /** + * Destroy a page from the system. + * + * @throws Exception + */ + public function destroy(Page $page): void + { + $this->trashCan->softDestroyPage($page); + Activity::add(ActivityType::PAGE_DELETE, $page); + $this->trashCan->autoClearOld(); + } + + /** + * Restores a revision's content back into a page. + */ + public function restoreRevision(Page $page, int $revisionId): Page + { + $oldUrl = $page->getUrl(); + $page->revision_count++; + + /** @var PageRevision $revision */ + $revision = $page->revisions()->where('id', '=', $revisionId)->first(); + + $page->fill($revision->toArray()); + $content = new PageContent($page); + + if (!empty($revision->markdown)) { + $content->setNewMarkdown($revision->markdown, user()); + } else { + $content->setNewHTML($revision->html, user()); + } + + $page->updated_by = user()->id; + $this->baseRepo->refreshSlug($page); + $page->save(); + $page->indexForSearch(); + $this->referenceStore->updateForEntity($page); + + $summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]); + $this->revisionRepo->storeNewForPage($page, $summary); + + if ($oldUrl !== $page->getUrl()) { + $this->referenceUpdater->updateEntityReferences($page, $oldUrl); + } + + Activity::add(ActivityType::PAGE_RESTORE, $page); + Activity::add(ActivityType::REVISION_RESTORE, $revision); + + $this->baseRepo->sortParent($page); + + return $page; + } + + /** + * Move the given page into a new parent book or chapter. + * The $parentIdentifier must be a string of the following format: + * 'book:' (book:5). + * + * @throws MoveOperationException + * @throws PermissionsException + */ + public function move(Page $page, string $parentIdentifier): Entity + { + $parent = $this->entityQueries->findVisibleByStringIdentifier($parentIdentifier); + if (!$parent instanceof Chapter && !$parent instanceof Book) { + throw new MoveOperationException('Book or chapter to move page into not found'); + } + + if (!userCan(Permission::PageCreate, $parent)) { + throw new PermissionsException('User does not have permission to create a page within the new parent'); + } + + return (new DatabaseTransaction(function () use ($page, $parent) { + $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; + $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id; + $this->parentChanger->changeBook($page, $newBookId); + $page->rebuildPermissions(); + + Activity::add(ActivityType::PAGE_MOVE, $page); + + $this->baseRepo->sortParent($page); + + return $parent; + }))->run(); + } + + /** + * Get a new priority for a page. + */ + protected function getNewPriority(Page $page): int + { + $parent = $page->getParent(); + if ($parent instanceof Chapter) { + /** @var ?Page $lastPage */ + $lastPage = $parent->pages('desc')->first(); + + return $lastPage ? $lastPage->priority + 1 : 0; + } + + return (new BookContents($page->book))->getLastPriority() + 1; + } +} diff --git a/app/Entities/Repos/RevisionRepo.php b/app/Entities/Repos/RevisionRepo.php new file mode 100644 index 00000000000..2d1371b63bd --- /dev/null +++ b/app/Entities/Repos/RevisionRepo.php @@ -0,0 +1,93 @@ +queries->latestCurrentUserDraftsForPageId($page->id)->delete(); + } + + /** + * Get a user update_draft page revision to update for the given page. + * Checks for an existing revision before providing a fresh one. + */ + public function getNewDraftForCurrentUser(Page $page): PageRevision + { + $draft = $this->queries->findLatestCurrentUserDraftsForPageId($page->id); + + if ($draft) { + return $draft; + } + + $draft = new PageRevision(); + $draft->page_id = $page->id; + $draft->slug = $page->slug; + $draft->book_slug = $page->book->slug; + $draft->created_by = user()->id; + $draft->type = 'update_draft'; + + return $draft; + } + + /** + * Store a new revision in the system for the given page. + */ + public function storeNewForPage(Page $page, ?string $summary = null): PageRevision + { + $revision = new PageRevision(); + + $revision->name = $page->name; + $revision->html = $page->html; + $revision->markdown = $page->markdown; + $revision->text = $page->text; + $revision->page_id = $page->id; + $revision->slug = $page->slug; + $revision->book_slug = $page->book->slug; + $revision->created_by = user()->id; + $revision->created_at = $page->updated_at; + $revision->type = 'version'; + $revision->summary = $summary; + $revision->revision_number = $page->revision_count; + $revision->save(); + + $this->deleteOldRevisions($page); + + return $revision; + } + + /** + * Delete old revisions, for the given page, from the system. + */ + protected function deleteOldRevisions(Page $page): void + { + $revisionLimit = config('app.revision_limit'); + if ($revisionLimit === false) { + return; + } + + $revisionsToDelete = PageRevision::query() + ->where('page_id', '=', $page->id) + ->orderBy('created_at', 'desc') + ->skip(intval($revisionLimit)) + ->take(10) + ->get(['id']); + + if ($revisionsToDelete->count() > 0) { + PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete(); + } + } +} diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php new file mode 100644 index 00000000000..4bbab626520 --- /dev/null +++ b/app/Entities/Tools/BookContents.php @@ -0,0 +1,105 @@ +queries = app()->make(EntityQueries::class); + } + + /** + * Get the current priority of the last item at the top-level of the book. + */ + public function getLastPriority(): int + { + $maxPage = $this->book->pages() + ->where('draft', '=', false) + ->whereDoesntHave('chapter') + ->max('priority'); + + $maxChapter = $this->book->chapters() + ->max('priority'); + + return max($maxChapter, $maxPage, 1); + } + + /** + * Get the contents as a sorted collection tree. + */ + public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection + { + $pages = $this->getPages($showDrafts, $renderPages); + $chapters = $this->book->chapters()->scopes('visible')->get(); + $all = collect()->concat($pages)->concat($chapters); + $chapterMap = $chapters->keyBy('id'); + $lonePages = collect(); + + $pages->groupBy('chapter_id')->each(function ($pages, $chapter_id) use ($chapterMap, &$lonePages) { + $chapter = $chapterMap->get($chapter_id); + if ($chapter) { + $chapter->setAttribute('visible_pages', collect($pages)->sortBy($this->bookChildSortFunc())); + } else { + $lonePages = $lonePages->concat($pages); + } + }); + + $chapters->whereNull('visible_pages')->each(function (Chapter $chapter) { + $chapter->setAttribute('visible_pages', collect([])); + }); + + $all->each(function (Entity $entity) use ($renderPages) { + $entity->setRelation('book', $this->book); + + if ($renderPages && $entity instanceof Page) { + $entity->html = (new PageContent($entity))->render(); + } + }); + + return collect($chapters)->concat($lonePages)->sortBy($this->bookChildSortFunc()); + } + + /** + * Function for providing a sorting score for an entity in relation to the + * other items within the book. + */ + protected function bookChildSortFunc(): callable + { + return function (Entity $entity) { + if ($entity->getAttribute('draft') ?? false) { + return -100; + } + + return $entity->getAttribute('priority') ?? 0; + }; + } + + /** + * Get the visible pages within this book. + */ + protected function getPages(bool $showDrafts = false, bool $getPageContent = false): Collection + { + if ($getPageContent) { + $query = $this->queries->pages->visibleWithContents(); + } else { + $query = $this->queries->pages->visibleForList(); + } + + if (!$showDrafts) { + $query->where('draft', '=', false); + } + + return $query->where('book_id', '=', $this->book->id)->get(); + } +} diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php new file mode 100644 index 00000000000..64c48c351ae --- /dev/null +++ b/app/Entities/Tools/Cloner.php @@ -0,0 +1,201 @@ +referenceChangeContext = new ReferenceChangeContext(); + } + + /** + * Clone the given page into the given parent using the provided name. + */ + public function clonePage(Page $original, Entity $parent, string $newName): Page + { + $context = $this->newReferenceChangeContext(); + $page = $this->createPageClone($original, $parent, $newName); + $this->referenceUpdater->changeReferencesUsingContext($context); + return $page; + } + + protected function createPageClone(Page $original, Entity $parent, string $newName): Page + { + $copyPage = $this->pageRepo->getNewDraftPage($parent); + $pageData = $this->entityToInputData($original); + $pageData['name'] = $newName; + + $newPage = $this->pageRepo->publishDraft($copyPage, $pageData); + $this->referenceChangeContext->add($original, $newPage); + + return $newPage; + } + + /** + * Clone the given page into the given parent using the provided name. + * Clones all child pages. + */ + public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter + { + $context = $this->newReferenceChangeContext(); + $chapter = $this->createChapterClone($original, $parent, $newName); + $this->referenceUpdater->changeReferencesUsingContext($context); + return $chapter; + } + + protected function createChapterClone(Chapter $original, Book $parent, string $newName): Chapter + { + $chapterDetails = $this->entityToInputData($original); + $chapterDetails['name'] = $newName; + + $copyChapter = $this->chapterRepo->create($chapterDetails, $parent); + + if (userCan(Permission::PageCreate, $copyChapter)) { + /** @var Page $page */ + foreach ($original->getVisiblePages() as $page) { + $this->createPageClone($page, $copyChapter, $page->name); + } + } + + $this->referenceChangeContext->add($original, $copyChapter); + + return $copyChapter; + } + + /** + * Clone the given book. + * Clones all child chapters and pages. + */ + public function cloneBook(Book $original, string $newName): Book + { + $context = $this->newReferenceChangeContext(); + $book = $this->createBookClone($original, $newName); + $this->referenceUpdater->changeReferencesUsingContext($context); + return $book; + } + + protected function createBookClone(Book $original, string $newName): Book + { + $bookDetails = $this->entityToInputData($original); + $bookDetails['name'] = $newName; + + // Clone book + $copyBook = $this->bookRepo->create($bookDetails); + + // Clone contents + $directChildren = $original->getDirectVisibleChildren(); + foreach ($directChildren as $child) { + if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) { + $this->createChapterClone($child, $copyBook, $child->name); + } + + if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) { + $this->createPageClone($child, $copyBook, $child->name); + } + } + + // Clone bookshelf relationships + /** @var Bookshelf $shelf */ + foreach ($original->shelves as $shelf) { + if (userCan(Permission::BookshelfUpdate, $shelf)) { + $shelf->appendBook($copyBook); + } + } + + $this->referenceChangeContext->add($original, $copyBook); + + return $copyBook; + } + + /** + * Convert an entity to a raw data array of input data. + * + * @return array + */ + public function entityToInputData(Entity $entity): array + { + $inputData = $entity->getAttributes(); + $inputData['tags'] = $this->entityTagsToInputArray($entity); + + // Add a cover to the data if existing on the original entity + if ($entity instanceof HasCoverInterface) { + $cover = $entity->coverInfo()->getImage(); + if ($cover) { + $inputData['image'] = $this->imageToUploadedFile($cover); + } + } + + return $inputData; + } + + /** + * Copy the permission settings from the source entity to the target entity. + */ + public function copyEntityPermissions(Entity $sourceEntity, Entity $targetEntity): void + { + $permissions = $sourceEntity->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray(); + $targetEntity->permissions()->delete(); + $targetEntity->permissions()->createMany($permissions); + $targetEntity->rebuildPermissions(); + } + + /** + * Convert an image instance to an UploadedFile instance to mimic + * a file being uploaded. + */ + protected function imageToUploadedFile(Image $image): ?UploadedFile + { + $imgData = $this->imageService->getImageData($image); + $tmpImgFilePath = tempnam(sys_get_temp_dir(), 'bs_cover_clone_'); + file_put_contents($tmpImgFilePath, $imgData); + + return new UploadedFile($tmpImgFilePath, basename($image->path)); + } + + /** + * Convert the tags on the given entity to the raw format + * that's used for incoming request data. + */ + protected function entityTagsToInputArray(Entity $entity): array + { + $tags = []; + + /** @var Tag $tag */ + foreach ($entity->tags as $tag) { + $tags[] = ['name' => $tag->name, 'value' => $tag->value]; + } + + return $tags; + } + + protected function newReferenceChangeContext(): ReferenceChangeContext + { + $this->referenceChangeContext = new ReferenceChangeContext(); + return $this->referenceChangeContext; + } +} diff --git a/app/Entities/Tools/EntityCover.php b/app/Entities/Tools/EntityCover.php new file mode 100644 index 00000000000..1e8fce201dd --- /dev/null +++ b/app/Entities/Tools/EntityCover.php @@ -0,0 +1,75 @@ +where('id', '=', $this->entity->image_id); + } + + /** + * Check if a cover image exists for this entity. + */ + public function exists(): bool + { + return $this->entity->image_id !== null && $this->imageQuery()->exists(); + } + + /** + * Get the assigned cover image model. + */ + public function getImage(): Image|null + { + if ($this->entity->image_id === null) { + return null; + } + + $cover = $this->imageQuery()->first(); + if ($cover instanceof Image) { + return $cover; + } + + return null; + } + + /** + * Returns a cover image URL, or the given default if none assigned/existing. + */ + public function getUrl(int $width = 440, int $height = 250, string|null $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='): string|null + { + if (!$this->entity->image_id) { + return $default; + } + + try { + return $this->getImage()?->getThumb($width, $height, false) ?? $default; + } catch (Exception $err) { + return $default; + } + } + + /** + * Set the image to use as the cover for this entity. + */ + public function setImage(Image|null $image): void + { + if ($image === null) { + $this->entity->image_id = null; + } else { + $this->entity->image_id = $image->id; + } + } +} diff --git a/app/Entities/Tools/EntityDefaultTemplate.php b/app/Entities/Tools/EntityDefaultTemplate.php new file mode 100644 index 00000000000..d36c3f270e8 --- /dev/null +++ b/app/Entities/Tools/EntityDefaultTemplate.php @@ -0,0 +1,60 @@ +entity->default_template_id); + if (!$changing) { + return; + } + + if ($templateId === 0) { + $this->entity->default_template_id = null; + return; + } + + $pageQueries = app()->make(PageQueries::class); + $templateExists = $pageQueries->visibleTemplates() + ->where('id', '=', $templateId) + ->exists(); + + $this->entity->default_template_id = $templateExists ? $templateId : null; + } + + /** + * Get the default template for this entity (if visible). + */ + public function get(): Page|null + { + if (!$this->entity->default_template_id) { + return null; + } + + $pageQueries = app()->make(PageQueries::class); + $page = $pageQueries->visibleTemplates(true) + ->where('id', '=', $this->entity->default_template_id) + ->first(); + + if ($page instanceof Page) { + return $page; + } + + return null; + } +} diff --git a/app/Entities/Tools/EntityHtmlDescription.php b/app/Entities/Tools/EntityHtmlDescription.php new file mode 100644 index 00000000000..052088c04d6 --- /dev/null +++ b/app/Entities/Tools/EntityHtmlDescription.php @@ -0,0 +1,67 @@ +html = $this->entity->description_html ?? ''; + $this->plain = $this->entity->description ?? ''; + } + + /** + * Update the description from HTML code. + * Optionally takes plaintext to use for the model also. + */ + public function set(string $html, string|null $plaintext = null): void + { + $this->html = $html; + $this->entity->description_html = $this->html; + + if ($plaintext !== null) { + $this->plain = $plaintext; + $this->entity->description = $this->plain; + } + + if (empty($html) && !empty($plaintext)) { + $this->html = $this->getHtml(); + $this->entity->description_html = $this->html; + } + } + + /** + * Get the description as HTML. + * Optionally returns the raw HTML if requested. + */ + public function getHtml(bool $raw = false): string + { + $html = $this->html ?: '

' . nl2br(e($this->plain)) . '

'; + if ($raw) { + return $html; + } + + $isEmpty = empty(trim(strip_tags($html))); + if ($isEmpty) { + return '

'; + } + + $filter = new HtmlContentFilter(new HtmlContentFilterConfig()); + return $filter->filterString($html); + } + + public function getPlain(): string + { + return $this->plain; + } +} diff --git a/app/Entities/Tools/EntityHydrator.php b/app/Entities/Tools/EntityHydrator.php new file mode 100644 index 00000000000..87e39d222ec --- /dev/null +++ b/app/Entities/Tools/EntityHydrator.php @@ -0,0 +1,140 @@ +getRawOriginal(); + $instance = Entity::instanceFromType($entity->type); + + if ($instance instanceof Page) { + $data['text'] = $data['description']; + unset($data['description']); + } + + $instance = $instance->setRawAttributes($data, true); + $hydrated[] = $instance; + } + + if ($loadTags) { + $this->loadTagsIntoModels($hydrated); + } + + if ($loadParents) { + $this->loadParentsIntoModels($hydrated); + } + + return $hydrated; + } + + /** + * @param Entity[] $entities + */ + protected function loadTagsIntoModels(array $entities): void + { + $idsByType = []; + $entityMap = []; + foreach ($entities as $entity) { + if (!isset($idsByType[$entity->type])) { + $idsByType[$entity->type] = []; + } + $idsByType[$entity->type][] = $entity->id; + $entityMap[$entity->type . ':' . $entity->id] = $entity; + } + + $query = Tag::query(); + foreach ($idsByType as $type => $ids) { + $query->orWhere(function ($query) use ($type, $ids) { + $query->where('entity_type', '=', $type) + ->whereIn('entity_id', $ids); + }); + } + + $tags = empty($idsByType) ? [] : $query->get()->all(); + $tagMap = []; + foreach ($tags as $tag) { + $key = $tag->entity_type . ':' . $tag->entity_id; + if (!isset($tagMap[$key])) { + $tagMap[$key] = []; + } + $tagMap[$key][] = $tag; + } + + foreach ($entityMap as $key => $entity) { + $entityTags = new Collection($tagMap[$key] ?? []); + $entity->setRelation('tags', $entityTags); + } + } + + /** + * @param Entity[] $entities + */ + protected function loadParentsIntoModels(array $entities): void + { + $parentsByType = ['book' => [], 'chapter' => []]; + + foreach ($entities as $entity) { + if ($entity->getAttribute('book_id') !== null) { + $parentsByType['book'][] = $entity->getAttribute('book_id'); + } + if ($entity->getAttribute('chapter_id') !== null) { + $parentsByType['chapter'][] = $entity->getAttribute('chapter_id'); + } + } + + $parentQuery = $this->entityQueries->visibleForList(); + $filtered = count($parentsByType['book']) > 0 || count($parentsByType['chapter']) > 0; + $parentQuery = $parentQuery->where(function ($query) use ($parentsByType) { + foreach ($parentsByType as $type => $ids) { + if (count($ids) > 0) { + $query = $query->orWhere(function ($query) use ($type, $ids) { + $query->where('type', '=', $type) + ->whereIn('id', $ids); + }); + } + } + }); + + $parentModels = $filtered ? $parentQuery->get()->all() : []; + $parents = $this->hydrate($parentModels); + $parentMap = []; + foreach ($parents as $parent) { + $parentMap[$parent->type . ':' . $parent->id] = $parent; + } + + foreach ($entities as $entity) { + if ($entity instanceof Page || $entity instanceof Chapter) { + $key = 'book:' . $entity->getRawAttribute('book_id'); + $entity->setRelation('book', $parentMap[$key] ?? null); + } + if ($entity instanceof Page) { + $key = 'chapter:' . $entity->getRawAttribute('chapter_id'); + $entity->setRelation('chapter', $parentMap[$key] ?? null); + } + } + } +} diff --git a/app/Entities/Tools/HierarchyTransformer.php b/app/Entities/Tools/HierarchyTransformer.php new file mode 100644 index 00000000000..c58d29bd073 --- /dev/null +++ b/app/Entities/Tools/HierarchyTransformer.php @@ -0,0 +1,84 @@ +cloner->entityToInputData($chapter); + $book = $this->bookRepo->create($inputData); + $this->cloner->copyEntityPermissions($chapter, $book); + + /** @var Page $page */ + foreach ($chapter->pages as $page) { + $page->chapter_id = 0; + $page->save(); + $this->parentChanger->changeBook($page, $book->id); + } + + $this->trashCan->destroyEntity($chapter); + + Activity::add(ActivityType::BOOK_CREATE_FROM_CHAPTER, $book); + + return $book; + } + + /** + * Transform a book into a shelf. + * Does not check permissions, check before calling. + */ + public function transformBookToShelf(Book $book): Bookshelf + { + $inputData = $this->cloner->entityToInputData($book); + $shelf = $this->shelfRepo->create($inputData, []); + $this->cloner->copyEntityPermissions($book, $shelf); + + $shelfBookSyncData = []; + + /** @var Chapter $chapter */ + foreach ($book->chapters as $index => $chapter) { + $newBook = $this->transformChapterToBook($chapter); + $shelfBookSyncData[$newBook->id] = ['order' => $index]; + if (!$newBook->hasPermissions()) { + $this->cloner->copyEntityPermissions($shelf, $newBook); + } + } + + if ($book->directPages->count() > 0) { + $book->name .= ' ' . trans('entities.pages'); + $shelfBookSyncData[$book->id] = ['order' => count($shelfBookSyncData) + 1]; + $book->save(); + } else { + $this->trashCan->destroyEntity($book); + } + + $shelf->books()->sync($shelfBookSyncData); + + Activity::add(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $shelf); + + return $shelf; + } +} diff --git a/app/Entities/Tools/Markdown/CheckboxConverter.php b/app/Entities/Tools/Markdown/CheckboxConverter.php new file mode 100644 index 00000000000..6d872330afb --- /dev/null +++ b/app/Entities/Tools/Markdown/CheckboxConverter.php @@ -0,0 +1,28 @@ +getAttribute('type')) === 'checkbox') { + $isChecked = $element->getAttribute('checked') === 'checked'; + + return $isChecked ? ' [x] ' : ' [ ] '; + } + + return $element->getValue(); + } + + /** + * @return string[] + */ + public function getSupportedTags(): array + { + return ['input']; + } +} diff --git a/app/Entities/Tools/Markdown/CustomDivConverter.php b/app/Entities/Tools/Markdown/CustomDivConverter.php new file mode 100644 index 00000000000..48606239094 --- /dev/null +++ b/app/Entities/Tools/Markdown/CustomDivConverter.php @@ -0,0 +1,20 @@ +getAttribute('drawio-diagram'); + if ($drawIoDiagram) { + return "
{$element->getValue()}
\n\n"; + } + + return parent::convert($element); + } +} diff --git a/app/Entities/Tools/Markdown/CustomImageConverter.php b/app/Entities/Tools/Markdown/CustomImageConverter.php new file mode 100644 index 00000000000..6642b292a69 --- /dev/null +++ b/app/Entities/Tools/Markdown/CustomImageConverter.php @@ -0,0 +1,25 @@ +getParent(); + + // Remain as HTML if within diagram block. + $withinDrawing = $parent && !empty($parent->getAttribute('drawio-diagram')); + if ($withinDrawing) { + $src = e($element->getAttribute('src')); + $alt = e($element->getAttribute('alt')); + + return "\"{$alt}\"/"; + } + + return parent::convert($element); + } +} diff --git a/app/Entities/Tools/Markdown/CustomListItemRenderer.php b/app/Entities/Tools/Markdown/CustomListItemRenderer.php new file mode 100644 index 00000000000..0c506d7f9b5 --- /dev/null +++ b/app/Entities/Tools/Markdown/CustomListItemRenderer.php @@ -0,0 +1,43 @@ +baseRenderer = new ListItemRenderer(); + } + + /** + * @return HtmlElement|string|null + */ + public function render(Node $node, ChildNodeRendererInterface $childRenderer) + { + $listItem = $this->baseRenderer->render($node, $childRenderer); + + if ($node instanceof ListItem && $this->startsTaskListItem($node) && $listItem instanceof HtmlElement) { + $listItem->setAttribute('class', 'task-list-item'); + } + + return $listItem; + } + + private function startsTaskListItem(ListItem $block): bool + { + $firstChild = $block->firstChild(); + + return $firstChild instanceof Paragraph && $firstChild->firstChild() instanceof TaskListItemMarker; + } +} diff --git a/app/Entities/Tools/Markdown/CustomParagraphConverter.php b/app/Entities/Tools/Markdown/CustomParagraphConverter.php new file mode 100644 index 00000000000..db36042cd71 --- /dev/null +++ b/app/Entities/Tools/Markdown/CustomParagraphConverter.php @@ -0,0 +1,19 @@ +getAttribute('class')); + if (strpos($class, 'callout') !== false) { + return "<{$element->getTagName()} class=\"{$class}\">{$element->getValue()}getTagName()}>\n\n"; + } + + return parent::convert($element); + } +} diff --git a/app/Entities/Tools/Markdown/CustomStrikeThroughExtension.php b/app/Entities/Tools/Markdown/CustomStrikeThroughExtension.php new file mode 100644 index 00000000000..ee4e9339751 --- /dev/null +++ b/app/Entities/Tools/Markdown/CustomStrikeThroughExtension.php @@ -0,0 +1,17 @@ +addDelimiterProcessor(new StrikethroughDelimiterProcessor()); + $environment->addRenderer(Strikethrough::class, new CustomStrikethroughRenderer()); + } +} diff --git a/app/Entities/Tools/Markdown/CustomStrikethroughRenderer.php b/app/Entities/Tools/Markdown/CustomStrikethroughRenderer.php new file mode 100644 index 00000000000..01b09377bb7 --- /dev/null +++ b/app/Entities/Tools/Markdown/CustomStrikethroughRenderer.php @@ -0,0 +1,24 @@ + HTML tags instead of in order to + * match front-end markdown-it rendering. + */ +class CustomStrikethroughRenderer implements NodeRendererInterface +{ + public function render(Node $node, ChildNodeRendererInterface $childRenderer) + { + Strikethrough::assertInstanceOf($node); + + return new HtmlElement('s', $node->data->get('attributes'), $childRenderer->renderNodes($node->children())); + } +} diff --git a/app/Entities/Tools/Markdown/HtmlToMarkdown.php b/app/Entities/Tools/Markdown/HtmlToMarkdown.php new file mode 100644 index 00000000000..473435c7f0c --- /dev/null +++ b/app/Entities/Tools/Markdown/HtmlToMarkdown.php @@ -0,0 +1,93 @@ +html = $html; + } + + /** + * Run the conversion. + */ + public function convert(): string + { + $converter = new HtmlConverter($this->getConverterEnvironment()); + $html = $this->prepareHtml($this->html); + + return $converter->convert($html); + } + + /** + * Run any pre-processing to the HTML to clean it up manually before conversion. + */ + protected function prepareHtml(string $html): string + { + // Carriage returns can cause whitespace issues in output + $html = str_replace("\r\n", "\n", $html); + // Attributes on the pre tag can cause issues with conversion + return preg_replace('/
/', '
', $html);
+    }
+
+    /**
+     * Get the HTML to Markdown customized environment.
+     * Extends the default provided environment with some BookStack specific tweaks.
+     */
+    protected function getConverterEnvironment(): Environment
+    {
+        $environment = new Environment([
+            'header_style'            => 'atx', // Set to 'atx' to output H1 and H2 headers as # Header1 and ## Header2
+            'suppress_errors'         => true, // Set to false to show warnings when loading malformed HTML
+            'strip_tags'              => false, // Set to true to strip tags that don't have markdown equivalents. N.B. Strips tags, not their content. Useful to clean MS Word HTML output.
+            'strip_placeholder_links' => false, // Set to true to remove  that doesn't have href.
+            'bold_style'              => '**', // DEPRECATED: Set to '__' if you prefer the underlined style
+            'italic_style'            => '*', // DEPRECATED: Set to '_' if you prefer the underlined style
+            'remove_nodes'            => '', // space-separated list of dom nodes that should be removed. example: 'meta style script'
+            'hard_break'              => false, // Set to true to turn 
into `\n` instead of ` \n` + 'list_item_style' => '-', // Set the default character for each
  • in a