Merge branch 'development' into default-templates

This commit is contained in:
Dan Brown
2023-12-11 11:41:43 +00:00
1548 changed files with 46899 additions and 21914 deletions

View File

@ -37,8 +37,10 @@ MAIL_FROM=bookstack@example.com
# SMTP mail options # SMTP mail options
# These settings can be checked using the "Send a Test Email" # These settings can be checked using the "Send a Test Email"
# feature found in the "Settings > Maintenance" area of the system. # 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_HOST=localhost
MAIL_PORT=1025 MAIL_PORT=587
MAIL_USERNAME=null MAIL_USERNAME=null
MAIL_PASSWORD=null MAIL_PASSWORD=null
MAIL_ENCRYPTION=null MAIL_ENCRYPTION=null

View File

@ -3,6 +3,10 @@
# Each option is shown with it's default value. # Each option is shown with it's default value.
# Do not copy this whole file to use as your '.env' file. # 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 # Application environment
# Can be 'production', 'development', 'testing' or 'demo' # Can be 'production', 'development', 'testing' or 'demo'
APP_ENV=production APP_ENV=production
@ -65,20 +69,20 @@ DB_PASSWORD=database_user_password
# certificate itself (Common Name or Subject Alternative Name). # certificate itself (Common Name or Subject Alternative Name).
MYSQL_ATTR_SSL_CA="/path/to/ca.pem" MYSQL_ATTR_SSL_CA="/path/to/ca.pem"
# Mail system to use # Mail configuration
# Can be 'smtp' or 'sendmail' # Refer to https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration
MAIL_DRIVER=smtp MAIL_DRIVER=smtp
MAIL_FROM=bookstack@example.com
# Mail sending options
MAIL_FROM=mail@bookstackapp.com
MAIL_FROM_NAME=BookStack MAIL_FROM_NAME=BookStack
# SMTP mail options
MAIL_HOST=localhost MAIL_HOST=localhost
MAIL_PORT=1025 MAIL_PORT=587
MAIL_USERNAME=null MAIL_USERNAME=null
MAIL_PASSWORD=null MAIL_PASSWORD=null
MAIL_ENCRYPTION=null MAIL_ENCRYPTION=null
MAIL_VERIFY_SSL=true
MAIL_SENDMAIL_COMMAND="/usr/sbin/sendmail -bs"
# Cache & Session driver to use # Cache & Session driver to use
# Can be 'file', 'database', 'memcached' or 'redis' # Can be 'file', 'database', 'memcached' or 'redis'
@ -268,6 +272,8 @@ OIDC_DUMP_USER_DETAILS=false
OIDC_USER_TO_GROUPS=false OIDC_USER_TO_GROUPS=false
OIDC_GROUPS_CLAIM=groups OIDC_GROUPS_CLAIM=groups
OIDC_REMOVE_FROM_GROUPS=false 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 # Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option # Service-specific options will override this option
@ -318,6 +324,13 @@ FILE_UPLOAD_SIZE_LIMIT=50
# Can be 'a4' or 'letter'. # Can be 'a4' or 'letter'.
EXPORT_PAGE_SIZE=a4 EXPORT_PAGE_SIZE=a4
# Set path to wkhtmltopdf binary for PDF generation.
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
# When false, BookStack will attempt to find a wkhtmltopdf in the application
# root folder then fall back to the default dompdf renderer if no binary exists.
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
WKHTMLTOPDF=false
# Allow <script> tags in page content # Allow <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts. # Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false ALLOW_CONTENT_SCRIPTS=false
@ -347,6 +360,15 @@ ALLOWED_IFRAME_HOSTS=null
# Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured. # 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" ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com"
# A list of the sources/hostnames that can be reached by application SSR calls.
# This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
# Host-specific functionality (usually controlled via other options) like auth
# or user avatars for example, won't use this list.
# Space seperated if multiple. Can use '*' as a wildcard.
# Values will be compared prefix-matched, case-insensitive, against called SSR urls.
# Defaults to allow all hosts.
ALLOWED_SSR_HOSTS="*"
# The default and maximum item-counts for listing API requests. # The default and maximum item-counts for listing API requests.
API_DEFAULT_ITEM_COUNT=100 API_DEFAULT_ITEM_COUNT=100
API_MAX_ITEM_COUNT=500 API_MAX_ITEM_COUNT=500

View File

@ -1,7 +1,14 @@
name: Bug Report name: Bug Report
description: Create a report to help us improve or fix things description: Create a report to help us fix bugs & issues in existing supported functionality
labels: [":bug: Bug"] labels: [":bug: Bug"]
body: 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 - type: textarea
id: description id: description
attributes: attributes:
@ -13,7 +20,7 @@ body:
id: reproduction id: reproduction
attributes: attributes:
label: Steps to Reproduce label: Steps to Reproduce
description: Detail the steps that would replicate this issue description: Detail the steps that would replicate this issue.
placeholder: | placeholder: |
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
@ -32,7 +39,7 @@ body:
id: context id: context
attributes: attributes:
label: Screenshots or Additional Context label: Screenshots or Additional Context
description: Provide any additional context and screenshots here to help us solve this issue description: Provide any additional context and screenshots here to help us solve this issue.
validations: validations:
required: false required: false
- type: input - type: input
@ -48,23 +55,7 @@ body:
id: bsversion id: bsversion
attributes: attributes:
label: Exact BookStack Version label: Exact BookStack Version
description: This can be found in the settings view of BookStack. Please provide an exact version. description: This can be found in the settings view of BookStack. Please provide an exact version(s) you've tested on.
placeholder: (eg. v21.08.5) placeholder: (eg. v23.06.7)
validations:
required: true
- type: input
id: phpversion
attributes:
label: PHP Version
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that relevant to the issue.
placeholder: (eg. 7.4)
validations:
required: false
- type: textarea
id: hosting
attributes:
label: Hosting Environment
description: Describe your hosting environment as much as possible including any proxies used (If applicable).
placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
validations: validations:
required: true required: true

View File

@ -33,9 +33,9 @@ body:
attributes: attributes:
label: Have you searched for an existing open/closed issue? label: Have you searched for an existing open/closed issue?
description: | description: |
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundemental benefit/goal of your request. To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundamental benefit/goal of your request.
options: options:
- label: I have searched for existing issues and none cover my fundemental request - label: I have searched for existing issues and none cover my fundamental request
required: true required: true
- type: dropdown - type: dropdown
id: existing_usage id: existing_usage
@ -43,8 +43,8 @@ body:
label: How long have you been using BookStack? label: How long have you been using BookStack?
options: options:
- Not using yet, just scoping - Not using yet, just scoping
- 0 to 6 months - Under 3 months
- 6 months to 1 year - 3 months to 1 year
- 1 to 5 years - 1 to 5 years
- Over 5 years - Over 5 years
validations: validations:

View File

@ -33,7 +33,7 @@ body:
attributes: attributes:
label: Exact BookStack Version label: Exact BookStack Version
description: This can be found in the settings view of BookStack. Please provide an exact version. description: This can be found in the settings view of BookStack. Please provide an exact version.
placeholder: (eg. v21.08.5) placeholder: (eg. v23.06.7)
validations: validations:
required: true required: true
- type: textarea - type: textarea
@ -44,19 +44,11 @@ body:
placeholder: Be sure to remove any confidential details in your logs placeholder: Be sure to remove any confidential details in your logs
validations: validations:
required: false required: false
- type: input
id: phpversion
attributes:
label: PHP Version
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that most relevant to the issue.
placeholder: (eg. 7.4)
validations:
required: false
- type: textarea - type: textarea
id: hosting id: hosting
attributes: attributes:
label: Hosting Environment label: Hosting Environment
description: Describe your hosting environment as much as possible including any proxies used (If applicable). description: Describe your hosting environment as much as possible including any proxies used (If applicable).
placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script) placeholder: (eg. PHP8.1 on Ubuntu 22.04 VPS, installed using official installation script)
validations: validations:
required: true required: true

13
.github/SECURITY.md vendored
View File

@ -15,15 +15,10 @@ If you'd like to be notified of new potential security concerns you can [sign-up
If you've found an issue that likely has no impact to existing users (For example, in a development-only branch) If you've found an issue that likely has no impact to existing users (For example, in a development-only branch)
feel free to raise it via a standard GitHub bug report issue. feel free to raise it via a standard GitHub bug report issue.
If the issue could have a security impact to BookStack instances, please use one of the below If the issue could have a security impact to BookStack instances,
methods to report the vulnerability: please directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown).
You will need to log in to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
- Directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown). Alternatively you can send a DM via Mastodon to [@danb@fosstodon.org](https://fosstodon.org/@danb).
- You will need to login to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
- Alternatively you can send a DM via Twitter to [@ssddanbrown](https://twitter.com/ssddanbrown).
- [Disclose via huntr.dev](https://huntr.dev/bounties/disclose)
- Bounties may be available to you through this platform.
- Be sure to use `https://github.com/BookStackApp/BookStack` as the repository URL.
Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability 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 can often take a little time due to the amount of preparation required, to ensure the vulnerability has

View File

@ -57,6 +57,7 @@ Name :: Languages
@Jokuna :: Korean @Jokuna :: Korean
@smartshogu :: German; German Informal @smartshogu :: German; German Informal
@samadha56 :: Persian @samadha56 :: Persian
@mrmuminov :: Uzbek
cipi1965 :: Italian cipi1965 :: Italian
Mykola Ronik (Mantikor) :: Ukrainian Mykola Ronik (Mantikor) :: Ukrainian
furkanoyk :: Turkish furkanoyk :: Turkish
@ -176,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish Michał Stelmach (stelmach-web) :: Polish
arniom :: French arniom :: French
REMOVED_USER :: ; Dutch; Turkish REMOVED_USER :: French; Dutch; Turkish;
林祖年 (contagion) :: Chinese Traditional 林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@ -269,7 +270,7 @@ mcgong (GongMingCai) :: Chinese Simplified; Chinese Traditional
Nanang Setia Budi (sefidananang) :: Indonesian Nanang Setia Budi (sefidananang) :: Indonesian
Андрей Павлов (andrei.pavlov) :: Russian Андрей Павлов (andrei.pavlov) :: Russian
Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian
Ji-Hyeon Gim (PotatoGim) :: Korean Jihyeon Gim (PotatoGim) :: Korean
Mihai Ochian (soulstorm19) :: Romanian Mihai Ochian (soulstorm19) :: Romanian
HeartCore :: German Informal; German HeartCore :: German Informal; German
simon.pct :: French simon.pct :: French
@ -283,13 +284,13 @@ Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
digilady :: Greek digilady :: Greek
Linus (LinusOP) :: Swedish Linus (LinusOP) :: Swedish
Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian
RandomUser0815 :: German RandomUser0815 :: German Informal; German
Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
구인회 (laskdjlaskdj12) :: Korean 구인회 (laskdjlaskdj12) :: Korean
LiZerui (CNLiZerui) :: Chinese Traditional LiZerui (CNLiZerui) :: Chinese Traditional
Fabrice Boyer (FabriceBoyer) :: French Fabrice Boyer (FabriceBoyer) :: French
mikael (bitcanon) :: Swedish mikael (bitcanon) :: Swedish
Matthias Mai (schnapsidee) :: German Matthias Mai (schnapsidee) :: German Informal; German
Ufuk Ayyıldız (ufukayyildiz) :: Turkish Ufuk Ayyıldız (ufukayyildiz) :: Turkish
Jan Mitrof (jan.kachlik) :: Czech Jan Mitrof (jan.kachlik) :: Czech
edwardsmirnov :: Russian edwardsmirnov :: Russian
@ -298,3 +299,75 @@ shotu :: French
Cesar_Lopez_Aguillon :: Spanish Cesar_Lopez_Aguillon :: Spanish
bdewoop :: German bdewoop :: German
dina davoudi (dina.davoudi) :: Persian 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
H.-H. 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
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

View File

@ -1,6 +1,12 @@
name: analyse-php name: analyse-php
on: [push, pull_request] on:
push:
paths:
- '**.php'
pull_request:
paths:
- '**.php'
jobs: jobs:
build: build:

24
.github/workflows/lint-js.yml vendored Normal file
View File

@ -0,0 +1,24 @@
name: lint-js
on:
push:
paths:
- '**.js'
- '**.json'
pull_request:
paths:
- '**.js'
- '**.json'
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v1
- name: Install NPM deps
run: npm ci
- name: Run formatting check
run: npm run lint

View File

@ -1,6 +1,12 @@
name: lint-php name: lint-php
on: [push, pull_request] on:
push:
paths:
- '**.php'
pull_request:
paths:
- '**.php'
jobs: jobs:
build: build:

View File

@ -1,6 +1,14 @@
name: test-migrations name: test-migrations
on: [push, pull_request] on:
push:
paths:
- '**.php'
- 'composer.*'
pull_request:
paths:
- '**.php'
- 'composer.*'
jobs: jobs:
build: build:
@ -8,7 +16,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:
php: ['7.4', '8.0', '8.1', '8.2'] php: ['8.0', '8.1', '8.2', '8.3']
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1

View File

@ -1,6 +1,14 @@
name: test-php name: test-php
on: [push, pull_request] on:
push:
paths:
- '**.php'
- 'composer.*'
pull_request:
paths:
- '**.php'
- 'composer.*'
jobs: jobs:
build: build:
@ -8,7 +16,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:
php: ['7.4', '8.0', '8.1', '8.2'] php: ['8.0', '8.1', '8.2', '8.3']
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
@ -16,7 +24,7 @@ jobs:
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp
- name: Get Composer Cache Directory - name: Get Composer Cache Directory
id: composer-cache id: composer-cache

6
.gitignore vendored
View File

@ -1,5 +1,7 @@
/vendor /vendor
/node_modules /node_modules
/.vscode
/composer
Homestead.yaml Homestead.yaml
.env .env
.idea .idea
@ -11,6 +13,7 @@ yarn-error.log
/public/js /public/js
/public/bower /public/bower
/public/build/ /public/build/
/public/favicon.ico
/storage/images /storage/images
_ide_helper.php _ide_helper.php
/storage/debugbar /storage/debugbar
@ -20,8 +23,11 @@ yarn.lock
nbproject nbproject
.buildpath .buildpath
.project .project
.nvmrc
.settings/ .settings/
webpack-stats.json webpack-stats.json
.phpunit.result.cache .phpunit.result.cache
.DS_Store .DS_Store
phpstan.neon phpstan.neon
esbuild-meta.json
.phpactor.json

View File

@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2015-2022, Dan Brown and the BookStack Project contributors. Copyright (c) 2015-2023, Dan Brown and the BookStack Project contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@ -1,34 +1,24 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use BookStack\Auth\Access\EmailConfirmationService; use BookStack\Access\EmailConfirmationService;
use BookStack\Auth\Access\LoginService; use BookStack\Access\LoginService;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ConfirmationEmailException; use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Exceptions\UserTokenExpiredException; use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException; use BookStack\Exceptions\UserTokenNotFoundException;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controller;
use BookStack\Users\UserRepo;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class ConfirmEmailController extends Controller class ConfirmEmailController extends Controller
{ {
protected EmailConfirmationService $emailConfirmationService;
protected LoginService $loginService;
protected UserRepo $userRepo;
/**
* Create a new controller instance.
*/
public function __construct( public function __construct(
EmailConfirmationService $emailConfirmationService, protected EmailConfirmationService $emailConfirmationService,
LoginService $loginService, protected LoginService $loginService,
UserRepo $userRepo protected UserRepo $userRepo
) { ) {
$this->emailConfirmationService = $emailConfirmationService;
$this->loginService = $loginService;
$this->userRepo = $userRepo;
} }
/** /**

View File

@ -1,19 +1,14 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use BookStack\Actions\ActivityType; use BookStack\Activity\ActivityType;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
class ForgotPasswordController extends Controller class ForgotPasswordController extends Controller
{ {
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct() public function __construct()
{ {
$this->middleware('guest'); $this->middleware('guest');
@ -30,10 +25,6 @@ class ForgotPasswordController extends Controller
/** /**
* Send a reset link to the given user. * Send a reset link to the given user.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\RedirectResponse
*/ */
public function sendResetLinkEmail(Request $request) public function sendResetLinkEmail(Request $request)
{ {
@ -56,13 +47,13 @@ class ForgotPasswordController extends Controller
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]); $message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
$this->showSuccessNotification($message); $this->showSuccessNotification($message);
return back()->with('status', trans($response)); return redirect('/password/email')->with('status', trans($response));
} }
// If an error was returned by the password broker, we will get this message // 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 // 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. // to where the users came from so they can attempt this process again.
return back()->withErrors( return redirect('/password/email')->withErrors(
['email' => trans($response)] ['email' => trans($response)]
); );
} }

View File

@ -1,10 +1,10 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use BookStack\Auth\Access\LoginService; use BookStack\Access\LoginService;
use BookStack\Auth\User;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use BookStack\Users\Models\User;
trait HandlesPartialLogins trait HandlesPartialLogins
{ {

View File

@ -1,36 +1,28 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use BookStack\Auth\Access\LoginService; use BookStack\Access\LoginService;
use BookStack\Auth\Access\SocialAuthService; use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\LoginAttemptEmailNeededException; use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException; use BookStack\Exceptions\LoginAttemptException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controller;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class LoginController extends Controller class LoginController extends Controller
{ {
use ThrottlesLogins; use ThrottlesLogins;
protected SocialAuthService $socialAuthService; public function __construct(
protected LoginService $loginService; protected SocialDriverManager $socialDriverManager,
protected LoginService $loginService,
/** ) {
* Create a new controller instance.
*/
public function __construct(SocialAuthService $socialAuthService, LoginService $loginService)
{
$this->middleware('guest', ['only' => ['getLogin', 'login']]); $this->middleware('guest', ['only' => ['getLogin', 'login']]);
$this->middleware('guard:standard,ldap', ['only' => ['login']]); $this->middleware('guard:standard,ldap', ['only' => ['login']]);
$this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]); $this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]);
$this->socialAuthService = $socialAuthService;
$this->loginService = $loginService;
} }
/** /**
@ -38,7 +30,7 @@ class LoginController extends Controller
*/ */
public function getLogin(Request $request) public function getLogin(Request $request)
{ {
$socialDrivers = $this->socialAuthService->getActiveDrivers(); $socialDrivers = $this->socialDriverManager->getActive();
$authMethod = config('auth.method'); $authMethod = config('auth.method');
$preventInitiation = $request->get('prevent_auto_init') === 'true'; $preventInitiation = $request->get('prevent_auto_init') === 'true';
@ -52,7 +44,7 @@ class LoginController extends Controller
// Store the previous location for redirect after login // Store the previous location for redirect after login
$this->updateIntendedFromPrevious(); $this->updateIntendedFromPrevious();
if (!$preventInitiation && $this->shouldAutoInitiate()) { if (!$preventInitiation && $this->loginService->shouldAutoInitiate()) {
return view('auth.login-initiate', [ return view('auth.login-initiate', [
'authMethod' => $authMethod, 'authMethod' => $authMethod,
]); ]);
@ -101,15 +93,9 @@ class LoginController extends Controller
/** /**
* Logout user and perform subsequent redirect. * Logout user and perform subsequent redirect.
*/ */
public function logout(Request $request) public function logout()
{ {
Auth::guard()->logout(); return redirect($this->loginService->logout());
$request->session()->invalidate();
$request->session()->regenerateToken();
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
return redirect($redirectUri);
} }
/** /**
@ -200,7 +186,7 @@ class LoginController extends Controller
{ {
// Store the previous location for redirect after login // Store the previous location for redirect after login
$previous = url()->previous(''); $previous = url()->previous('');
$isPreviousFromInstance = (strpos($previous, url('/')) === 0); $isPreviousFromInstance = str_starts_with($previous, url('/'));
if (!$previous || !setting('app-public') || !$isPreviousFromInstance) { if (!$previous || !setting('app-public') || !$isPreviousFromInstance) {
return; return;
} }
@ -211,23 +197,11 @@ class LoginController extends Controller
]; ];
foreach ($ignorePrefixList as $ignorePrefix) { foreach ($ignorePrefixList as $ignorePrefix) {
if (strpos($previous, url($ignorePrefix)) === 0) { if (str_starts_with($previous, url($ignorePrefix))) {
return; return;
} }
} }
redirect()->setIntendedUrl($previous); redirect()->setIntendedUrl($previous);
} }
/**
* Check if login auto-initiate should be valid based upon authentication config.
*/
protected function shouldAutoInitiate(): bool
{
$socialDrivers = $this->socialAuthService->getActiveDrivers();
$authMethod = config('auth.method');
$autoRedirect = config('auth.auto_initiate');
return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
}
} }

View File

@ -1,14 +1,14 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use BookStack\Actions\ActivityType; use BookStack\Access\LoginService;
use BookStack\Auth\Access\LoginService; use BookStack\Access\Mfa\BackupCodeService;
use BookStack\Auth\Access\Mfa\BackupCodeService; use BookStack\Access\Mfa\MfaSession;
use BookStack\Auth\Access\Mfa\MfaSession; use BookStack\Access\Mfa\MfaValue;
use BookStack\Auth\Access\Mfa\MfaValue; use BookStack\Activity\ActivityType;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controller;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;

View File

@ -1,10 +1,10 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use BookStack\Actions\ActivityType; use BookStack\Access\Mfa\MfaValue;
use BookStack\Auth\Access\Mfa\MfaValue; use BookStack\Activity\ActivityType;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class MfaController extends Controller class MfaController extends Controller

View File

@ -1,15 +1,15 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use BookStack\Actions\ActivityType; use BookStack\Access\LoginService;
use BookStack\Auth\Access\LoginService; use BookStack\Access\Mfa\MfaSession;
use BookStack\Auth\Access\Mfa\MfaSession; use BookStack\Access\Mfa\MfaValue;
use BookStack\Auth\Access\Mfa\MfaValue; use BookStack\Access\Mfa\TotpService;
use BookStack\Auth\Access\Mfa\TotpService; use BookStack\Access\Mfa\TotpValidationRule;
use BookStack\Auth\Access\Mfa\TotpValidationRule; use BookStack\Activity\ActivityType;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;

View File

@ -1,19 +1,16 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use BookStack\Auth\Access\Oidc\OidcException; use BookStack\Access\Oidc\OidcException;
use BookStack\Auth\Access\Oidc\OidcService; use BookStack\Access\Oidc\OidcService;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class OidcController extends Controller class OidcController extends Controller
{ {
protected OidcService $oidcService; protected OidcService $oidcService;
/**
* OpenIdController constructor.
*/
public function __construct(OidcService $oidcService) public function __construct(OidcService $oidcService)
{ {
$this->oidcService = $oidcService; $this->oidcService = $oidcService;
@ -63,4 +60,12 @@ class OidcController extends Controller
return redirect()->intended(); return redirect()->intended();
} }
/**
* Log the user out then start the OIDC RP-initiated logout process.
*/
public function logout()
{
return redirect($this->oidcService->logout());
}
} }

View File

@ -1,13 +1,13 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use BookStack\Auth\Access\LoginService; use BookStack\Access\LoginService;
use BookStack\Auth\Access\RegistrationService; use BookStack\Access\RegistrationService;
use BookStack\Auth\Access\SocialAuthService; use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controller;
use Illuminate\Contracts\Validation\Validator as ValidatorContract; use Illuminate\Contracts\Validation\Validator as ValidatorContract;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@ -15,7 +15,7 @@ use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller class RegisterController extends Controller
{ {
protected SocialAuthService $socialAuthService; protected SocialDriverManager $socialDriverManager;
protected RegistrationService $registrationService; protected RegistrationService $registrationService;
protected LoginService $loginService; protected LoginService $loginService;
@ -23,14 +23,14 @@ class RegisterController extends Controller
* Create a new controller instance. * Create a new controller instance.
*/ */
public function __construct( public function __construct(
SocialAuthService $socialAuthService, SocialDriverManager $socialDriverManager,
RegistrationService $registrationService, RegistrationService $registrationService,
LoginService $loginService LoginService $loginService
) { ) {
$this->middleware('guest'); $this->middleware('guest');
$this->middleware('guard:standard'); $this->middleware('guard:standard');
$this->socialAuthService = $socialAuthService; $this->socialDriverManager = $socialDriverManager;
$this->registrationService = $registrationService; $this->registrationService = $registrationService;
$this->loginService = $loginService; $this->loginService = $loginService;
} }
@ -43,7 +43,7 @@ class RegisterController extends Controller
public function getRegister() public function getRegister()
{ {
$this->registrationService->ensureRegistrationAllowed(); $this->registrationService->ensureRegistrationAllowed();
$socialDrivers = $this->socialAuthService->getActiveDrivers(); $socialDrivers = $this->socialDriverManager->getActive();
return view('auth.register', [ return view('auth.register', [
'socialDrivers' => $socialDrivers, 'socialDrivers' => $socialDrivers,

View File

@ -1,11 +1,11 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use BookStack\Actions\ActivityType; use BookStack\Access\LoginService;
use BookStack\Auth\Access\LoginService; use BookStack\Activity\ActivityType;
use BookStack\Auth\User; use BookStack\Http\Controller;
use BookStack\Http\Controllers\Controller; use BookStack\Users\Models\User;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@ -66,7 +66,7 @@ class ResetPasswordController extends Controller
// redirect them back to where they came from with their error message. // redirect them back to where they came from with their error message.
return $response === Password::PASSWORD_RESET return $response === Password::PASSWORD_RESET
? $this->sendResetResponse() ? $this->sendResetResponse()
: $this->sendResetFailedResponse($request, $response); : $this->sendResetFailedResponse($request, $response, $request->get('token'));
} }
/** /**
@ -83,7 +83,7 @@ class ResetPasswordController extends Controller
/** /**
* Get the response for a failed password reset. * Get the response for a failed password reset.
*/ */
protected function sendResetFailedResponse(Request $request, string $response): RedirectResponse protected function sendResetFailedResponse(Request $request, string $response, string $token): RedirectResponse
{ {
// We show invalid users as invalid tokens as to not leak what // We show invalid users as invalid tokens as to not leak what
// users may exist in the system. // users may exist in the system.
@ -91,7 +91,7 @@ class ResetPasswordController extends Controller
$response = Password::INVALID_TOKEN; $response = Password::INVALID_TOKEN;
} }
return redirect()->back() return redirect("/password/reset/{$token}")
->withInput($request->only('email')) ->withInput($request->only('email'))
->withErrors(['email' => trans($response)]); ->withErrors(['email' => trans($response)]);
} }

View File

@ -1,22 +1,17 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use BookStack\Auth\Access\Saml2Service; use BookStack\Access\Saml2Service;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class Saml2Controller extends Controller class Saml2Controller extends Controller
{ {
protected Saml2Service $samlService; public function __construct(
protected Saml2Service $samlService
/** ) {
* Saml2Controller constructor.
*/
public function __construct(Saml2Service $samlService)
{
$this->samlService = $samlService;
$this->middleware('guard:saml2'); $this->middleware('guard:saml2');
} }
@ -36,7 +31,12 @@ class Saml2Controller extends Controller
*/ */
public function logout() public function logout()
{ {
$logoutDetails = $this->samlService->logout(auth()->user()); $user = user();
if ($user->isGuest()) {
return redirect('/login');
}
$logoutDetails = $this->samlService->logout($user);
if ($logoutDetails['id']) { if ($logoutDetails['id']) {
session()->flash('saml2_logout_request_id', $logoutDetails['id']); session()->flash('saml2_logout_request_id', $logoutDetails['id']);
@ -64,7 +64,7 @@ class Saml2Controller extends Controller
public function sls() public function sls()
{ {
$requestId = session()->pull('saml2_logout_request_id', null); $requestId = session()->pull('saml2_logout_request_id', null);
$redirect = $this->samlService->processSlsResponse($requestId) ?? '/'; $redirect = $this->samlService->processSlsResponse($requestId);
return redirect($redirect); return redirect($redirect);
} }

View File

@ -1,37 +1,27 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use BookStack\Auth\Access\LoginService; use BookStack\Access\LoginService;
use BookStack\Auth\Access\RegistrationService; use BookStack\Access\RegistrationService;
use BookStack\Auth\Access\SocialAuthService; use BookStack\Access\SocialAuthService;
use BookStack\Exceptions\SocialDriverNotConfigured; use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed; use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\SocialSignInException; use BookStack\Exceptions\SocialSignInException;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\User as SocialUser; use Laravel\Socialite\Contracts\User as SocialUser;
class SocialController extends Controller class SocialController extends Controller
{ {
protected SocialAuthService $socialAuthService;
protected RegistrationService $registrationService;
protected LoginService $loginService;
/**
* SocialController constructor.
*/
public function __construct( public function __construct(
SocialAuthService $socialAuthService, protected SocialAuthService $socialAuthService,
RegistrationService $registrationService, protected RegistrationService $registrationService,
LoginService $loginService protected LoginService $loginService,
) { ) {
$this->middleware('guest')->only(['register']); $this->middleware('guest')->only(['register']);
$this->socialAuthService = $socialAuthService;
$this->registrationService = $registrationService;
$this->loginService = $loginService;
} }
/** /**
@ -89,7 +79,7 @@ class SocialController extends Controller
try { try {
return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser); return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser);
} catch (SocialSignInAccountNotUsed $exception) { } catch (SocialSignInAccountNotUsed $exception) {
if ($this->socialAuthService->driverAutoRegisterEnabled($socialDriver)) { if ($this->socialAuthService->drivers()->isAutoRegisterEnabled($socialDriver)) {
return $this->socialRegisterCallback($socialDriver, $socialUser); return $this->socialRegisterCallback($socialDriver, $socialUser);
} }
@ -101,7 +91,7 @@ class SocialController extends Controller
return $this->socialRegisterCallback($socialDriver, $socialUser); return $this->socialRegisterCallback($socialDriver, $socialUser);
} }
return redirect()->back(); return redirect('/');
} }
/** /**
@ -112,7 +102,7 @@ class SocialController extends Controller
$this->socialAuthService->detachSocialAccount($socialDriver); $this->socialAuthService->detachSocialAccount($socialDriver);
session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => Str::title($socialDriver)])); session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => Str::title($socialDriver)]));
return redirect(user()->getEditUrl()); return redirect('/my-account/auth#social-accounts');
} }
/** /**
@ -124,7 +114,7 @@ class SocialController extends Controller
{ {
$socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser); $socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
$socialAccount = $this->socialAuthService->newSocialAccount($socialDriver, $socialUser); $socialAccount = $this->socialAuthService->newSocialAccount($socialDriver, $socialUser);
$emailVerified = $this->socialAuthService->driverAutoConfirmEmailEnabled($socialDriver); $emailVerified = $this->socialAuthService->drivers()->isAutoConfirmEmailEnabled($socialDriver);
// Create an array of the user data to create a new user instance // Create an array of the user data to create a new user instance
$userData = [ $userData = [

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use Illuminate\Cache\RateLimiter; use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -71,7 +71,7 @@ trait ThrottlesLogins
*/ */
protected function limiter(): RateLimiter protected function limiter(): RateLimiter
{ {
return app(RateLimiter::class); return app()->make(RateLimiter::class);
} }
/** /**

View File

@ -1,12 +1,12 @@
<?php <?php
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Access\Controllers;
use BookStack\Auth\Access\UserInviteService; use BookStack\Access\UserInviteService;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserTokenExpiredException; use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException; use BookStack\Exceptions\UserTokenNotFoundException;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controller;
use BookStack\Users\UserRepo;
use Exception; use Exception;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;

View File

@ -1,15 +1,15 @@
<?php <?php
namespace BookStack\Auth\Access; namespace BookStack\Access;
use BookStack\Auth\User; use BookStack\Access\Notifications\ConfirmEmailNotification;
use BookStack\Exceptions\ConfirmationEmailException; use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Notifications\ConfirmEmail; use BookStack\Users\Models\User;
class EmailConfirmationService extends UserTokenService class EmailConfirmationService extends UserTokenService
{ {
protected $tokenTable = 'email_confirmations'; protected string $tokenTable = 'email_confirmations';
protected $expiryTime = 24; protected int $expiryTime = 24;
/** /**
* Create new confirmation for a user, * Create new confirmation for a user,
@ -26,7 +26,7 @@ class EmailConfirmationService extends UserTokenService
$this->deleteByUser($user); $this->deleteByUser($user);
$token = $this->createTokenForUser($user); $token = $this->createTokenForUser($user);
$user->notify(new ConfirmEmail($token)); $user->notify(new ConfirmEmailNotification($token));
} }
/** /**

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Auth\Access; namespace BookStack\Access;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider; use Illuminate\Contracts\Auth\UserProvider;

View File

@ -1,9 +1,9 @@
<?php <?php
namespace BookStack\Auth\Access; namespace BookStack\Access;
use BookStack\Auth\Role; use BookStack\Users\Models\Role;
use BookStack\Auth\User; use BookStack\Users\Models\User;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class GroupSyncService class GroupSyncService

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Auth\Access\Guards; namespace BookStack\Access\Guards;
/** /**
* Saml2 Session Guard. * Saml2 Session Guard.

View File

@ -1,8 +1,8 @@
<?php <?php
namespace BookStack\Auth\Access\Guards; namespace BookStack\Access\Guards;
use BookStack\Auth\Access\RegistrationService; use BookStack\Access\RegistrationService;
use Illuminate\Auth\GuardHelpers; use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\StatefulGuard; use Illuminate\Contracts\Auth\StatefulGuard;

View File

@ -1,15 +1,15 @@
<?php <?php
namespace BookStack\Auth\Access\Guards; namespace BookStack\Access\Guards;
use BookStack\Auth\Access\LdapService; use BookStack\Access\LdapService;
use BookStack\Auth\Access\RegistrationService; use BookStack\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException; use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\LdapException; use BookStack\Exceptions\LdapException;
use BookStack\Exceptions\LoginAttemptEmailNeededException; use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException; use BookStack\Exceptions\LoginAttemptException;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Users\Models\User;
use Illuminate\Contracts\Auth\UserProvider; use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Session\Session; use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Str; use Illuminate\Support\Str;

110
app/Access/Ldap.php Normal file
View File

@ -0,0 +1,110 @@
<?php
namespace BookStack\Access;
/**
* Class Ldap
* An object-orientated thin abstraction wrapper for common PHP LDAP functions.
* Allows the standard LDAP functions to be mocked for testing.
*/
class Ldap
{
/**
* Connect to an LDAP server.
*
* @return resource|\LDAP\Connection|false
*/
public function connect(string $hostName)
{
return ldap_connect($hostName);
}
/**
* Set the value of an LDAP option for the given connection.
*
* @param resource|\LDAP\Connection|null $ldapConnection
*/
public function setOption($ldapConnection, int $option, mixed $value): bool
{
return ldap_set_option($ldapConnection, $option, $value);
}
/**
* Start TLS on the given LDAP connection.
*/
public function startTls($ldapConnection): bool
{
return ldap_start_tls($ldapConnection);
}
/**
* Set the version number for the given LDAP connection.
*
* @param resource|\LDAP\Connection $ldapConnection
*/
public function setVersion($ldapConnection, int $version): bool
{
return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
}
/**
* Search LDAP tree using the provided filter.
*
* @param resource|\LDAP\Connection $ldapConnection
*
* @return resource|\LDAP\Result
*/
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = null)
{
return ldap_search($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 = null): 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);
}
}

View File

@ -1,11 +1,11 @@
<?php <?php
namespace BookStack\Auth\Access; namespace BookStack\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException; use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\LdapException; use BookStack\Exceptions\LdapException;
use BookStack\Uploads\UserAvatars; use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\User;
use ErrorException; use ErrorException;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -15,26 +15,19 @@ use Illuminate\Support\Facades\Log;
*/ */
class LdapService class LdapService
{ {
protected Ldap $ldap;
protected GroupSyncService $groupSyncService;
protected UserAvatars $userAvatars;
/** /**
* @var resource * @var resource|\LDAP\Connection
*/ */
protected $ldapConnection; protected $ldapConnection;
protected array $config; protected array $config;
protected bool $enabled; protected bool $enabled;
/** public function __construct(
* LdapService constructor. protected Ldap $ldap,
*/ protected UserAvatars $userAvatars,
public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService) protected GroupSyncService $groupSyncService
{ ) {
$this->ldap = $ldap;
$this->userAvatars = $userAvatars;
$this->groupSyncService = $groupSyncService;
$this->config = config('services.ldap'); $this->config = config('services.ldap');
$this->enabled = config('auth.method') === 'ldap'; $this->enabled = config('auth.method') === 'ldap';
} }
@ -59,7 +52,7 @@ class LdapService
// Clean attributes // Clean attributes
foreach ($attributes as $index => $attribute) { foreach ($attributes as $index => $attribute) {
if (strpos($attribute, 'BIN;') === 0) { if (str_starts_with($attribute, 'BIN;')) {
$attributes[$index] = substr($attribute, strlen('BIN;')); $attributes[$index] = substr($attribute, strlen('BIN;'));
} }
} }
@ -82,7 +75,7 @@ class LdapService
* Get the details of a user from LDAP using the given username. * Get the details of a user from LDAP using the given username.
* User found via configurable user filter. * User found via configurable user filter.
* *
* @throws LdapException * @throws LdapException|JsonDebugException
*/ */
public function getUserDetails(string $userName): ?array public function getUserDetails(string $userName): ?array
{ {
@ -126,7 +119,7 @@ class LdapService
*/ */
protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue) protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
{ {
$isBinary = strpos($propertyKey, 'BIN;') === 0; $isBinary = str_starts_with($propertyKey, 'BIN;');
$propertyKey = strtolower($propertyKey); $propertyKey = strtolower($propertyKey);
$value = $defaultValue; $value = $defaultValue;
@ -170,11 +163,11 @@ class LdapService
* Bind the system user to the LDAP connection using the given credentials * Bind the system user to the LDAP connection using the given credentials
* otherwise anonymous access is attempted. * otherwise anonymous access is attempted.
* *
* @param resource $connection * @param resource|\LDAP\Connection $connection
* *
* @throws LdapException * @throws LdapException
*/ */
protected function bindSystemUser($connection) protected function bindSystemUser($connection): void
{ {
$ldapDn = $this->config['dn']; $ldapDn = $this->config['dn'];
$ldapPass = $this->config['pass']; $ldapPass = $this->config['pass'];
@ -197,7 +190,7 @@ class LdapService
* *
* @throws LdapException * @throws LdapException
* *
* @return resource * @return resource|\LDAP\Connection
*/ */
protected function getConnection() protected function getConnection()
{ {
@ -216,8 +209,8 @@ class LdapService
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER); $this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
} }
$serverDetails = $this->parseServerString($this->config['server']); $ldapHost = $this->parseServerString($this->config['server']);
$ldapConnection = $this->ldap->connect($serverDetails['host'], $serverDetails['port']); $ldapConnection = $this->ldap->connect($ldapHost);
if ($ldapConnection === false) { if ($ldapConnection === false) {
throw new LdapException(trans('errors.ldap_cannot_connect')); throw new LdapException(trans('errors.ldap_cannot_connect'));
@ -242,23 +235,16 @@ class LdapService
} }
/** /**
* Parse a LDAP server string and return the host and port for a connection. * 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'. * Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
*/ */
protected function parseServerString(string $serverString): array protected function parseServerString(string $serverString): string
{ {
$serverNameParts = explode(':', $serverString); if (str_starts_with($serverString, 'ldaps://') || str_starts_with($serverString, 'ldap://')) {
return $serverString;
// If we have a protocol just return the full string since PHP will ignore a separate port.
if ($serverNameParts[0] === 'ldaps' || $serverNameParts[0] === 'ldap') {
return ['host' => $serverString, 'port' => 389];
} }
// Otherwise, extract the port out return "ldap://{$serverString}";
$hostName = $serverNameParts[0];
$ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
return ['host' => $hostName, 'port' => $ldapPort];
} }
/** /**
@ -386,7 +372,7 @@ class LdapService
* @throws LdapException * @throws LdapException
* @throws JsonDebugException * @throws JsonDebugException
*/ */
public function syncGroups(User $user, string $username) public function syncGroups(User $user, string $username): void
{ {
$userLdapGroups = $this->getUserGroups($username); $userLdapGroups = $this->getUserGroups($username);
$this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']); $this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);

View File

@ -1,28 +1,26 @@
<?php <?php
namespace BookStack\Auth\Access; namespace BookStack\Access;
use BookStack\Actions\ActivityType; use BookStack\Access\Mfa\MfaSession;
use BookStack\Auth\Access\Mfa\MfaSession; use BookStack\Activity\ActivityType;
use BookStack\Auth\User;
use BookStack\Exceptions\LoginAttemptException; use BookStack\Exceptions\LoginAttemptException;
use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use Exception; use Exception;
class LoginService class LoginService
{ {
protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted'; protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
protected $mfaSession; public function __construct(
protected $emailConfirmationService; protected MfaSession $mfaSession,
protected EmailConfirmationService $emailConfirmationService,
public function __construct(MfaSession $mfaSession, EmailConfirmationService $emailConfirmationService) protected SocialDriverManager $socialDriverManager,
{ ) {
$this->mfaSession = $mfaSession;
$this->emailConfirmationService = $emailConfirmationService;
} }
/** /**
@ -163,4 +161,33 @@ class LoginService
return $result; return $result;
} }
/**
* 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']);
}
} }

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Auth\Access\Mfa; namespace BookStack\Access\Mfa;
use Illuminate\Support\Str; use Illuminate\Support\Str;

View File

@ -1,8 +1,8 @@
<?php <?php
namespace BookStack\Auth\Access\Mfa; namespace BookStack\Access\Mfa;
use BookStack\Auth\User; use BookStack\Users\Models\User;
class MfaSession class MfaSession
{ {

View File

@ -1,8 +1,8 @@
<?php <?php
namespace BookStack\Auth\Access\Mfa; namespace BookStack\Access\Mfa;
use BookStack\Auth\User; use BookStack\Users\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Auth\Access\Mfa; namespace BookStack\Access\Mfa;
use BaconQrCode\Renderer\Color\Rgb; use BaconQrCode\Renderer\Color\Rgb;
use BaconQrCode\Renderer\Image\SvgImageBackEnd; use BaconQrCode\Renderer\Image\SvgImageBackEnd;
@ -8,7 +8,7 @@ use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\Fill; use BaconQrCode\Renderer\RendererStyle\Fill;
use BaconQrCode\Renderer\RendererStyle\RendererStyle; use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer; use BaconQrCode\Writer;
use BookStack\Auth\User; use BookStack\Users\Models\User;
use PragmaRX\Google2FA\Google2FA; use PragmaRX\Google2FA\Google2FA;
use PragmaRX\Google2FA\Support\Constants; use PragmaRX\Google2FA\Support\Constants;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Auth\Access\Mfa; namespace BookStack\Access\Mfa;
use Illuminate\Contracts\Validation\Rule; use Illuminate\Contracts\Validation\Rule;

View File

@ -0,0 +1,26 @@
<?php
namespace BookStack\Access\Notifications;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class ConfirmEmailNotification extends MailNotification
{
public function __construct(
public string $token
) {
}
public function toMail(User $notifiable): MailMessage
{
$appName = ['appName' => setting('app-name')];
return $this->newMailMessage()
->subject(trans('auth.email_confirm_subject', $appName))
->greeting(trans('auth.email_confirm_greeting', $appName))
->line(trans('auth.email_confirm_text'))
->action(trans('auth.email_confirm_action'), url('/register/confirm/' . $this->token));
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace BookStack\Access\Notifications;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class ResetPasswordNotification extends MailNotification
{
public function __construct(
public string $token
) {
}
public function toMail(User $notifiable): MailMessage
{
return $this->newMailMessage()
->subject(trans('auth.email_reset_subject', ['appName' => setting('app-name')]))
->line(trans('auth.email_reset_text'))
->action(trans('auth.reset_password'), url('password/reset/' . $this->token))
->line(trans('auth.email_reset_not_requested'));
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace BookStack\Access\Notifications;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class UserInviteNotification extends MailNotification
{
public function __construct(
public string $token
) {
}
public function toMail(User $notifiable): MailMessage
{
$appName = ['appName' => setting('app-name')];
$locale = $notifiable->getLocale();
return $this->newMailMessage($locale)
->subject($locale->trans('auth.user_invite_email_subject', $appName))
->greeting($locale->trans('auth.user_invite_email_greeting', $appName))
->line($locale->trans('auth.user_invite_email_text'))
->action($locale->trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
}
}

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Auth\Access\Oidc; namespace BookStack\Access\Oidc;
use InvalidArgumentException; use InvalidArgumentException;
use League\OAuth2\Client\Token\AccessToken; use League\OAuth2\Client\Token\AccessToken;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Auth\Access\Oidc; namespace BookStack\Access\Oidc;
use Exception; use Exception;

View File

@ -1,38 +1,19 @@
<?php <?php
namespace BookStack\Auth\Access\Oidc; namespace BookStack\Access\Oidc;
class OidcIdToken class OidcIdToken
{ {
/** protected array $header;
* @var array protected array $payload;
*/ protected string $signature;
protected $header; protected string $issuer;
protected array $tokenParts = [];
/**
* @var array
*/
protected $payload;
/**
* @var string
*/
protected $signature;
/** /**
* @var array[]|string[] * @var array[]|string[]
*/ */
protected $keys; protected array $keys;
/**
* @var string
*/
protected $issuer;
/**
* @var array
*/
protected $tokenParts = [];
public function __construct(string $token, string $issuer, array $keys) public function __construct(string $token, string $issuer, array $keys)
{ {
@ -106,6 +87,14 @@ class OidcIdToken
return $this->payload; 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. * 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. * As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.

View File

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

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Auth\Access\Oidc; namespace BookStack\Access\Oidc;
use Exception; use Exception;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Auth\Access\Oidc; namespace BookStack\Access\Oidc;
use Exception; use Exception;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Auth\Access\Oidc; namespace BookStack\Access\Oidc;
use phpseclib3\Crypt\Common\PublicKey; use phpseclib3\Crypt\Common\PublicKey;
use phpseclib3\Crypt\PublicKeyLoader; use phpseclib3\Crypt\PublicKeyLoader;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Auth\Access\Oidc; namespace BookStack\Access\Oidc;
use League\OAuth2\Client\Grant\AbstractGrant; use League\OAuth2\Client\Grant\AbstractGrant;
use League\OAuth2\Client\Provider\AbstractProvider; use League\OAuth2\Client\Provider\AbstractProvider;
@ -20,15 +20,8 @@ class OidcOAuthProvider extends AbstractProvider
{ {
use BearerAuthorizationTrait; use BearerAuthorizationTrait;
/** protected string $authorizationEndpoint;
* @var string protected string $tokenEndpoint;
*/
protected $authorizationEndpoint;
/**
* @var string
*/
protected $tokenEndpoint;
/** /**
* Scopes to use for the OIDC authorization call. * Scopes to use for the OIDC authorization call.
@ -60,7 +53,7 @@ class OidcOAuthProvider extends AbstractProvider
} }
/** /**
* Add an additional scope to this provider upon the default. * Add another scope to this provider upon the default.
*/ */
public function addScope(string $scope): void public function addScope(string $scope): void
{ {

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Auth\Access\Oidc; namespace BookStack\Access\Oidc;
use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Request;
use Illuminate\Contracts\Cache\Repository; use Illuminate\Contracts\Cache\Repository;
@ -21,6 +21,7 @@ class OidcProviderSettings
public ?string $redirectUri; public ?string $redirectUri;
public ?string $authorizationEndpoint; public ?string $authorizationEndpoint;
public ?string $tokenEndpoint; public ?string $tokenEndpoint;
public ?string $endSessionEndpoint;
/** /**
* @var string[]|array[] * @var string[]|array[]
@ -59,7 +60,7 @@ class OidcProviderSettings
} }
} }
if (strpos($this->issuer, 'https://') !== 0) { if (!str_starts_with($this->issuer, 'https://')) {
throw new InvalidArgumentException('Issuer value must start with https://'); throw new InvalidArgumentException('Issuer value must start with https://');
} }
} }
@ -132,6 +133,10 @@ class OidcProviderSettings
$discoveredSettings['keys'] = $this->filterKeys($keys); $discoveredSettings['keys'] = $this->filterKeys($keys);
} }
if (!empty($result['end_session_endpoint'])) {
$discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
}
return $discoveredSettings; return $discoveredSettings;
} }

View File

@ -1,19 +1,21 @@
<?php <?php
namespace BookStack\Auth\Access\Oidc; namespace BookStack\Access\Oidc;
use BookStack\Auth\Access\GroupSyncService; use BookStack\Access\GroupSyncService;
use BookStack\Auth\Access\LoginService; use BookStack\Access\LoginService;
use BookStack\Auth\Access\RegistrationService; use BookStack\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException; use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider; use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Http\Client\ClientInterface as HttpClient;
/** /**
* Class OpenIdConnectService * Class OpenIdConnectService
@ -21,24 +23,12 @@ use Psr\Http\Client\ClientInterface as HttpClient;
*/ */
class OidcService class OidcService
{ {
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected HttpClient $httpClient;
protected GroupSyncService $groupService;
/**
* OpenIdService constructor.
*/
public function __construct( public function __construct(
RegistrationService $registrationService, protected RegistrationService $registrationService,
LoginService $loginService, protected LoginService $loginService,
HttpClient $httpClient, protected HttpRequestService $http,
GroupSyncService $groupService protected GroupSyncService $groupService
) { ) {
$this->registrationService = $registrationService;
$this->loginService = $loginService;
$this->httpClient = $httpClient;
$this->groupService = $groupService;
} }
/** /**
@ -94,6 +84,7 @@ class OidcService
'redirectUri' => url('/oidc/callback'), 'redirectUri' => url('/oidc/callback'),
'authorizationEndpoint' => $config['authorization_endpoint'], 'authorizationEndpoint' => $config['authorization_endpoint'],
'tokenEndpoint' => $config['token_endpoint'], 'tokenEndpoint' => $config['token_endpoint'],
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
]); ]);
// Use keys if configured // Use keys if configured
@ -104,12 +95,20 @@ class OidcService
// Run discovery // Run discovery
if ($config['discover'] ?? false) { if ($config['discover'] ?? false) {
try { try {
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15); $settings->discoverFromIssuer($this->http->buildClient(5), Cache::store(null), 15);
} catch (OidcIssuerDiscoveryException $exception) { } catch (OidcIssuerDiscoveryException $exception) {
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage()); 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(); $settings->validate();
return $settings; return $settings;
@ -121,7 +120,7 @@ class OidcService
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{ {
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [ $provider = new OidcOAuthProvider($settings->arrayForProvider(), [
'httpClient' => $this->httpClient, 'httpClient' => $this->http->buildClient(5),
'optionProvider' => new HttpBasicAuthOptionProvider(), 'optionProvider' => new HttpBasicAuthOptionProvider(),
]); ]);
@ -152,10 +151,11 @@ class OidcService
*/ */
protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
{ {
$displayNameAttr = $this->config()['display_name_claims']; $displayNameAttrString = $this->config()['display_name_claims'] ?? '';
$displayNameAttrs = explode('|', $displayNameAttrString);
$displayName = []; $displayName = [];
foreach ($displayNameAttr as $dnAttr) { foreach ($displayNameAttrs as $dnAttr) {
$dnComponent = $token->getClaim($dnAttr) ?? ''; $dnComponent = $token->getClaim($dnAttr) ?? '';
if ($dnComponent !== '') { if ($dnComponent !== '') {
$displayName[] = $dnComponent; $displayName[] = $dnComponent;
@ -198,7 +198,8 @@ class OidcService
*/ */
protected function getUserDetails(OidcIdToken $token): array protected function getUserDetails(OidcIdToken $token): array
{ {
$id = $token->getClaim('sub'); $idClaim = $this->config()['external_id_claim'];
$id = $token->getClaim($idClaim);
return [ return [
'external_id' => $id, 'external_id' => $id,
@ -225,6 +226,18 @@ class OidcService
$settings->keys, $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']) { if ($this->config()['dump_user_details']) {
throw new JsonDebugException($idToken->getAllClaims()); throw new JsonDebugException($idToken->getAllClaims());
} }
@ -282,4 +295,30 @@ class OidcService
{ {
return $this->config()['user_to_groups'] !== false; 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);
}
} }

View File

@ -1,15 +1,14 @@
<?php <?php
namespace BookStack\Auth\Access; namespace BookStack\Access;
use BookStack\Actions\ActivityType; use BookStack\Activity\ActivityType;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use BookStack\Users\UserRepo;
use Exception; use Exception;
use Illuminate\Support\Str; use Illuminate\Support\Str;

View File

@ -1,12 +1,12 @@
<?php <?php
namespace BookStack\Auth\Access; namespace BookStack\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException; use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\SamlException; use BookStack\Exceptions\SamlException;
use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Users\Models\User;
use Exception; use Exception;
use OneLogin\Saml2\Auth; use OneLogin\Saml2\Auth;
use OneLogin\Saml2\Constants; use OneLogin\Saml2\Constants;
@ -21,19 +21,13 @@ use OneLogin\Saml2\ValidationError;
class Saml2Service class Saml2Service
{ {
protected array $config; protected array $config;
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected GroupSyncService $groupSyncService;
public function __construct( public function __construct(
RegistrationService $registrationService, protected RegistrationService $registrationService,
LoginService $loginService, protected LoginService $loginService,
GroupSyncService $groupSyncService protected GroupSyncService $groupSyncService
) { ) {
$this->config = config('saml2'); $this->config = config('saml2');
$this->registrationService = $registrationService;
$this->loginService = $loginService;
$this->groupSyncService = $groupSyncService;
} }
/** /**
@ -54,20 +48,23 @@ class Saml2Service
/** /**
* Initiate a logout flow. * Initiate a logout flow.
* Returns the SAML2 request ID, and the URL to redirect the user to.
* *
* @throws Error * @throws Error
* @returns array{url: string, id: ?string}
*/ */
public function logout(User $user): array public function logout(User $user): array
{ {
$toolKit = $this->getToolkit(); $toolKit = $this->getToolkit();
$returnRoute = url('/'); $sessionIndex = session()->get('saml2_session_index');
$returnUrl = url($this->loginService->logout());
try { try {
$url = $toolKit->logout( $url = $toolKit->logout(
$returnRoute, $returnUrl,
[], [],
$user->email, $user->email,
null, $sessionIndex,
true, true,
Constants::NAMEID_EMAIL_ADDRESS Constants::NAMEID_EMAIL_ADDRESS
); );
@ -77,8 +74,7 @@ class Saml2Service
throw $error; throw $error;
} }
$this->actionLogout(); $url = $returnUrl;
$url = '/';
$id = null; $id = null;
} }
@ -118,6 +114,7 @@ class Saml2Service
$attrs = $toolkit->getAttributes(); $attrs = $toolkit->getAttributes();
$id = $toolkit->getNameId(); $id = $toolkit->getNameId();
session()->put('saml2_session_index', $toolkit->getSessionIndex());
return $this->processLoginCallback($id, $attrs); return $this->processLoginCallback($id, $attrs);
} }
@ -127,7 +124,7 @@ class Saml2Service
* *
* @throws Error * @throws Error
*/ */
public function processSlsResponse(?string $requestId): ?string public function processSlsResponse(?string $requestId): string
{ {
$toolkit = $this->getToolkit(); $toolkit = $this->getToolkit();
@ -136,7 +133,7 @@ class Saml2Service
// value so that the exact encoding format is matched when checking the signature. // 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 // This is primarily due to ADFS encoding query params with lowercase percent encoding while
// PHP (And most other sensible providers) standardise on uppercase. // PHP (And most other sensible providers) standardise on uppercase.
$redirect = $toolkit->processSLO(true, $requestId, true, null, true); $samlRedirect = $toolkit->processSLO(true, $requestId, true, null, true);
$errors = $toolkit->getErrors(); $errors = $toolkit->getErrors();
if (!empty($errors)) { if (!empty($errors)) {
@ -145,18 +142,9 @@ class Saml2Service
); );
} }
$this->actionLogout(); $defaultBookStackRedirect = $this->loginService->logout();
return $redirect; return $samlRedirect ?? $defaultBookStackRedirect;
}
/**
* Do the required actions to log a user out.
*/
protected function actionLogout()
{
auth()->logout();
session()->invalidate();
} }
/** /**
@ -356,6 +344,10 @@ class Saml2Service
$userDetails = $this->getUserDetails($samlID, $samlAttributes); $userDetails = $this->getUserDetails($samlID, $samlAttributes);
$isLoggedIn = auth()->check(); $isLoggedIn = auth()->check();
if ($this->shouldSyncGroups()) {
$userDetails['groups'] = $this->getUserGroups($samlAttributes);
}
if ($this->config['dump_user_details']) { if ($this->config['dump_user_details']) {
throw new JsonDebugException([ throw new JsonDebugException([
'id_from_idp' => $samlID, 'id_from_idp' => $samlID,
@ -378,13 +370,8 @@ class Saml2Service
$userDetails['external_id'] $userDetails['external_id']
); );
if ($user === null) {
throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
}
if ($this->shouldSyncGroups()) { if ($this->shouldSyncGroups()) {
$groups = $this->getUserGroups($samlAttributes); $this->groupSyncService->syncUserWithFoundGroups($user, $userDetails['groups'], $this->config['remove_from_groups']);
$this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']);
} }
$this->loginService->login($user, 'saml2'); $this->loginService->login($user, 'saml2');

View File

@ -1,9 +1,10 @@
<?php <?php
namespace BookStack\Auth; namespace BookStack\Access;
use BookStack\Interfaces\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\Model; use BookStack\App\Model;
use BookStack\Users\Models\User;
/** /**
* Class SocialAccount. * Class SocialAccount.

View File

@ -1,70 +1,25 @@
<?php <?php
namespace BookStack\Auth\Access; namespace BookStack\Access;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\User;
use BookStack\Exceptions\SocialDriverNotConfigured; use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed; use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use Illuminate\Support\Facades\Event; use BookStack\Users\Models\User;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite; use Laravel\Socialite\Contracts\Factory as Socialite;
use Laravel\Socialite\Contracts\Provider; use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Contracts\User as SocialUser; use Laravel\Socialite\Contracts\User as SocialUser;
use Laravel\Socialite\Two\GoogleProvider; use Laravel\Socialite\Two\GoogleProvider;
use SocialiteProviders\Manager\SocialiteWasCalled;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
class SocialAuthService class SocialAuthService
{ {
/** public function __construct(
* The core socialite library used. protected Socialite $socialite,
* protected LoginService $loginService,
* @var Socialite protected SocialDriverManager $driverManager,
*/ ) {
protected $socialite;
/**
* @var LoginService
*/
protected $loginService;
/**
* The default built-in social drivers we support.
*
* @var string[]
*/
protected $validSocialDrivers = [
'google',
'github',
'facebook',
'slack',
'twitter',
'azure',
'okta',
'gitlab',
'twitch',
'discord',
];
/**
* Callbacks to run when configuring a social driver
* for an initial redirect action.
* Array is keyed by social driver name.
* Callbacks are passed an instance of the driver.
*
* @var array<string, callable>
*/
protected $configureForRedirectCallbacks = [];
/**
* SocialAuthService constructor.
*/
public function __construct(Socialite $socialite, LoginService $loginService)
{
$this->socialite = $socialite;
$this->loginService = $loginService;
} }
/** /**
@ -74,9 +29,10 @@ class SocialAuthService
*/ */
public function startLogIn(string $socialDriver): RedirectResponse public function startLogIn(string $socialDriver): RedirectResponse
{ {
$driver = $this->validateDriver($socialDriver); $socialDriver = trim(strtolower($socialDriver));
$this->driverManager->ensureDriverActive($socialDriver);
return $this->getDriverForRedirect($driver)->redirect(); return $this->getDriverForRedirect($socialDriver)->redirect();
} }
/** /**
@ -86,9 +42,10 @@ class SocialAuthService
*/ */
public function startRegister(string $socialDriver): RedirectResponse public function startRegister(string $socialDriver): RedirectResponse
{ {
$driver = $this->validateDriver($socialDriver); $socialDriver = trim(strtolower($socialDriver));
$this->driverManager->ensureDriverActive($socialDriver);
return $this->getDriverForRedirect($driver)->redirect(); return $this->getDriverForRedirect($socialDriver)->redirect();
} }
/** /**
@ -119,9 +76,10 @@ class SocialAuthService
*/ */
public function getSocialUser(string $socialDriver): SocialUser public function getSocialUser(string $socialDriver): SocialUser
{ {
$driver = $this->validateDriver($socialDriver); $socialDriver = trim(strtolower($socialDriver));
$this->driverManager->ensureDriverActive($socialDriver);
return $this->socialite->driver($driver)->user(); return $this->socialite->driver($socialDriver)->user();
} }
/** /**
@ -131,6 +89,7 @@ class SocialAuthService
*/ */
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser) public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
{ {
$socialDriver = trim(strtolower($socialDriver));
$socialId = $socialUser->getId(); $socialId = $socialUser->getId();
// Get any attached social accounts or users // Get any attached social accounts or users
@ -154,21 +113,21 @@ class SocialAuthService
$currentUser->socialAccounts()->save($account); $currentUser->socialAccounts()->save($account);
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver])); session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl()); 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. // When a user is logged in and the social account exists and is already linked to the current user.
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) { if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver])); session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl()); return redirect('/my-account/auth#social_accounts');
} }
// When a user is logged in, A social account exists but the users do not match. // When a user is logged in, A social account exists but the users do not match.
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) { if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver])); session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl()); return redirect('/my-account/auth#social_accounts');
} }
// Otherwise let the user know this social account is not used by anyone. // Otherwise let the user know this social account is not used by anyone.
@ -181,75 +140,11 @@ class SocialAuthService
} }
/** /**
* Ensure the social driver is correct and supported. * Get the social driver manager used by this service.
*
* @throws SocialDriverNotConfigured
*/ */
protected function validateDriver(string $socialDriver): string public function drivers(): SocialDriverManager
{ {
$driver = trim(strtolower($socialDriver)); return $this->driverManager;
if (!in_array($driver, $this->validSocialDrivers)) {
abort(404, trans('errors.social_driver_not_found'));
}
if (!$this->checkDriverConfigured($driver)) {
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($socialDriver)]));
}
return $driver;
}
/**
* 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);
}
/**
* Gets the names of the active social drivers.
*/
public function getActiveDrivers(): array
{
$activeDrivers = [];
foreach ($this->validSocialDrivers as $driverKey) {
if ($this->checkDriverConfigured($driverKey)) {
$activeDrivers[$driverKey] = $this->getDriverName($driverKey);
}
}
return $activeDrivers;
}
/**
* Get the presentational name for a driver.
*/
public function getDriverName(string $driver): string
{
return config('services.' . strtolower($driver) . '.name');
}
/**
* Check if the current config for the given driver allows auto-registration.
*/
public function driverAutoRegisterEnabled(string $driver): bool
{
return config('services.' . strtolower($driver) . '.auto_register') === true;
}
/**
* Check if the current config for the given driver allow email address auto-confirmation.
*/
public function driverAutoConfirmEmailEnabled(string $driver): bool
{
return config('services.' . strtolower($driver) . '.auto_confirm') === true;
} }
/** /**
@ -283,33 +178,8 @@ class SocialAuthService
$driver->with(['prompt' => 'select_account']); $driver->with(['prompt' => 'select_account']);
} }
if (isset($this->configureForRedirectCallbacks[$driverName])) { $this->driverManager->getConfigureForRedirectCallback($driverName)($driver);
$this->configureForRedirectCallbacks[$driverName]($driver);
}
return $driver; return $driver;
} }
/**
* Add a custom socialite driver to be used.
* Driver name should be lower_snake_case.
* Config array should mirror the structure of a service
* within the `Config/services.php` file.
* Handler should be a Class@method handler to the SocialiteWasCalled event.
*/
public function addSocialDriver(
string $driverName,
array $config,
string $socialiteHandler,
callable $configureForRedirect = null
) {
$this->validSocialDrivers[] = $driverName;
config()->set('services.' . $driverName, $config);
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
if (!is_null($configureForRedirect)) {
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
}
}
} }

View File

@ -0,0 +1,147 @@
<?php
namespace BookStack\Access;
use BookStack\Exceptions\SocialDriverNotConfigured;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use SocialiteProviders\Manager\SocialiteWasCalled;
class SocialDriverManager
{
/**
* The default built-in social drivers we support.
*
* @var string[]
*/
protected array $validDrivers = [
'google',
'github',
'facebook',
'slack',
'twitter',
'azure',
'okta',
'gitlab',
'twitch',
'discord',
];
/**
* Callbacks to run when configuring a social driver
* for an initial redirect action.
* Array is keyed by social driver name.
* Callbacks are passed an instance of the driver.
*
* @var array<string, callable>
*/
protected array $configureForRedirectCallbacks = [];
/**
* Check if the current config for the given driver allows auto-registration.
*/
public function isAutoRegisterEnabled(string $driver): bool
{
return $this->getDriverConfigProperty($driver, 'auto_register') === true;
}
/**
* Check if the current config for the given driver allow email address auto-confirmation.
*/
public function isAutoConfirmEmailEnabled(string $driver): bool
{
return $this->getDriverConfigProperty($driver, 'auto_confirm') === true;
}
/**
* Gets the names of the active social drivers, keyed by driver id.
* @returns array<string, string>
*/
public function getActive(): array
{
$activeDrivers = [];
foreach ($this->validDrivers as $driverKey) {
if ($this->checkDriverConfigured($driverKey)) {
$activeDrivers[$driverKey] = $this->getName($driverKey);
}
}
return $activeDrivers;
}
/**
* Get the configure-for-redirect callback for the given driver.
* This is a callable that allows modification of the driver at redirect time.
* Commonly used to perform custom dynamic configuration where required.
* The callback is passed a \Laravel\Socialite\Contracts\Provider instance.
*/
public function getConfigureForRedirectCallback(string $driver): callable
{
return $this->configureForRedirectCallbacks[$driver] ?? (fn() => true);
}
/**
* Add a custom socialite driver to be used.
* Driver name should be lower_snake_case.
* Config array should mirror the structure of a service
* within the `Config/services.php` file.
* Handler should be a Class@method handler to the SocialiteWasCalled event.
*/
public function addSocialDriver(
string $driverName,
array $config,
string $socialiteHandler,
callable $configureForRedirect = null
) {
$this->validDrivers[] = $driverName;
config()->set('services.' . $driverName, $config);
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
if (!is_null($configureForRedirect)) {
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
}
}
/**
* Get the presentational name for a driver.
*/
protected function getName(string $driver): string
{
return $this->getDriverConfigProperty($driver, 'name') ?? '';
}
protected function getDriverConfigProperty(string $driver, string $property): mixed
{
return config("services.{$driver}.{$property}");
}
/**
* Ensure the social driver is correct and supported.
*
* @throws SocialDriverNotConfigured
*/
public function ensureDriverActive(string $driverName): void
{
if (!in_array($driverName, $this->validDrivers)) {
abort(404, trans('errors.social_driver_not_found'));
}
if (!$this->checkDriverConfigured($driverName)) {
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($driverName)]));
}
}
/**
* Check a social driver has been configured correctly.
*/
protected function checkDriverConfigured(string $driver): bool
{
$lowerName = strtolower($driver);
$configPrefix = 'services.' . $lowerName . '.';
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
return !in_array(false, $config) && !in_array(null, $config);
}
}

View File

@ -1,25 +1,23 @@
<?php <?php
namespace BookStack\Auth\Access; namespace BookStack\Access;
use BookStack\Auth\User; use BookStack\Access\Notifications\UserInviteNotification;
use BookStack\Notifications\UserInvite; use BookStack\Users\Models\User;
class UserInviteService extends UserTokenService class UserInviteService extends UserTokenService
{ {
protected $tokenTable = 'user_invites'; protected string $tokenTable = 'user_invites';
protected $expiryTime = 336; // Two weeks protected int $expiryTime = 336; // Two weeks
/** /**
* Send an invitation to a user to sign into BookStack * Send an invitation to a user to sign into BookStack
* Removes existing invitation tokens. * Removes existing invitation tokens.
*
* @param User $user
*/ */
public function sendInvitation(User $user) public function sendInvitation(User $user)
{ {
$this->deleteByUser($user); $this->deleteByUser($user);
$token = $this->createTokenForUser($user); $token = $this->createTokenForUser($user);
$user->notify(new UserInvite($token)); $user->notify(new UserInviteNotification($token));
} }
} }

View File

@ -1,10 +1,10 @@
<?php <?php
namespace BookStack\Auth\Access; namespace BookStack\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\UserTokenExpiredException; use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException; use BookStack\Exceptions\UserTokenNotFoundException;
use BookStack\Users\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -14,41 +14,29 @@ class UserTokenService
{ {
/** /**
* Name of table where user tokens are stored. * Name of table where user tokens are stored.
*
* @var string
*/ */
protected $tokenTable = 'user_tokens'; protected string $tokenTable = 'user_tokens';
/** /**
* Token expiry time in hours. * Token expiry time in hours.
*
* @var int
*/ */
protected $expiryTime = 24; protected int $expiryTime = 24;
/** /**
* Delete all email confirmations that belong to a user. * Delete all tokens that belong to a user.
*
* @param User $user
*
* @return mixed
*/ */
public function deleteByUser(User $user) public function deleteByUser(User $user): void
{ {
return DB::table($this->tokenTable) DB::table($this->tokenTable)
->where('user_id', '=', $user->id) ->where('user_id', '=', $user->id)
->delete(); ->delete();
} }
/** /**
* Get the user id from a token, while check the token exists and has not expired. * Get the user id from a token, while checking the token exists and has not expired.
*
* @param string $token
* *
* @throws UserTokenNotFoundException * @throws UserTokenNotFoundException
* @throws UserTokenExpiredException * @throws UserTokenExpiredException
*
* @return int
*/ */
public function checkTokenAndGetUserId(string $token): int public function checkTokenAndGetUserId(string $token): int
{ {
@ -67,8 +55,6 @@ class UserTokenService
/** /**
* Creates a unique token within the email confirmation database. * Creates a unique token within the email confirmation database.
*
* @return string
*/ */
protected function generateToken(): string protected function generateToken(): string
{ {
@ -82,10 +68,6 @@ class UserTokenService
/** /**
* Generate and store a token for the given user. * Generate and store a token for the given user.
*
* @param User $user
*
* @return string
*/ */
protected function createTokenForUser(User $user): string protected function createTokenForUser(User $user): string
{ {
@ -102,10 +84,6 @@ class UserTokenService
/** /**
* Check if the given token exists. * Check if the given token exists.
*
* @param string $token
*
* @return bool
*/ */
protected function tokenExists(string $token): bool protected function tokenExists(string $token): bool
{ {
@ -115,12 +93,8 @@ class UserTokenService
/** /**
* Get a token entry for the given token. * Get a token entry for the given token.
*
* @param string $token
*
* @return object|null
*/ */
protected function getEntryByToken(string $token) protected function getEntryByToken(string $token): ?stdClass
{ {
return DB::table($this->tokenTable) return DB::table($this->tokenTable)
->where('token', '=', $token) ->where('token', '=', $token)
@ -129,10 +103,6 @@ class UserTokenService
/** /**
* Check if the given token entry has expired. * Check if the given token entry has expired.
*
* @param stdClass $tokenEntry
*
* @return bool
*/ */
protected function entryExpired(stdClass $tokenEntry): bool protected function entryExpired(stdClass $tokenEntry): bool
{ {

View File

@ -1,60 +0,0 @@
<?php
namespace BookStack\Actions;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property string $text
* @property string $html
* @property int|null $parent_id
* @property int $local_id
*/
class Comment extends Model
{
use HasFactory;
use HasCreatorAndUpdater;
protected $fillable = ['text', 'parent_id'];
protected $appends = ['created', 'updated'];
/**
* Get the entity that this comment belongs to.
*/
public function entity(): MorphTo
{
return $this->morphTo('entity');
}
/**
* Check if a comment has been updated since creation.
*/
public function isUpdated(): bool
{
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();
}
}

View File

@ -1,82 +0,0 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\User;
use BookStack\Facades\Theme;
use BookStack\Interfaces\Loggable;
use BookStack\Theming\ThemeEvents;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class DispatchWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
protected Webhook $webhook;
protected string $event;
protected User $initiator;
protected int $initiatedTime;
/**
* @var string|Loggable
*/
protected $detail;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Webhook $webhook, string $event, $detail)
{
$this->webhook = $webhook;
$this->event = $event;
$this->detail = $detail;
$this->initiator = user();
$this->initiatedTime = time();
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime);
$webhookData = $themeResponse ?? WebhookFormatter::getDefault($this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime)->format();
$lastError = null;
try {
$response = Http::asJson()
->withOptions(['allow_redirects' => ['strict' => true]])
->timeout($this->webhook->timeout)
->post($this->webhook->endpoint, $webhookData);
} catch (\Exception $exception) {
$lastError = $exception->getMessage();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
}
if (isset($response) && $response->failed()) {
$lastError = "Response status from endpoint was {$response->status()}";
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
}
$this->webhook->last_called_at = now();
if ($lastError) {
$this->webhook->last_errored_at = now();
$this->webhook->last_error = $lastError;
}
$this->webhook->save();
}
}

View File

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

View File

@ -1,13 +1,14 @@
<?php <?php
namespace BookStack\Actions; namespace BookStack\Activity;
use BookStack\Auth\Permissions\PermissionApplicator; use BookStack\Activity\Models\Activity;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Actions; namespace BookStack\Activity;
class ActivityType class ActivityType
{ {
@ -27,6 +27,10 @@ class ActivityType
const BOOKSHELF_DELETE = 'bookshelf_delete'; const BOOKSHELF_DELETE = 'bookshelf_delete';
const COMMENTED_ON = 'commented_on'; const COMMENTED_ON = 'commented_on';
const COMMENT_CREATE = 'comment_create';
const COMMENT_UPDATE = 'comment_update';
const COMMENT_DELETE = 'comment_delete';
const PERMISSIONS_UPDATE = 'permissions_update'; const PERMISSIONS_UPDATE = 'permissions_update';
const REVISION_RESTORE = 'revision_restore'; const REVISION_RESTORE = 'revision_restore';

View File

@ -1,32 +1,20 @@
<?php <?php
namespace BookStack\Actions; namespace BookStack\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Facades\Activity as ActivityService; use BookStack\Facades\Activity as ActivityService;
use League\CommonMark\CommonMarkConverter; use League\CommonMark\CommonMarkConverter;
/**
* Class CommentRepo.
*/
class CommentRepo class CommentRepo
{ {
/**
* @var Comment
*/
protected $comment;
public function __construct(Comment $comment)
{
$this->comment = $comment;
}
/** /**
* Get a comment by ID. * Get a comment by ID.
*/ */
public function getById(int $id): Comment public function getById(int $id): Comment
{ {
return $this->comment->newQuery()->findOrFail($id); return Comment::query()->findOrFail($id);
} }
/** /**
@ -35,7 +23,7 @@ class CommentRepo
public function create(Entity $entity, string $text, ?int $parent_id): Comment public function create(Entity $entity, string $text, ?int $parent_id): Comment
{ {
$userId = user()->id; $userId = user()->id;
$comment = $this->comment->newInstance(); $comment = new Comment();
$comment->text = $text; $comment->text = $text;
$comment->html = $this->commentToHtml($text); $comment->html = $this->commentToHtml($text);
@ -45,6 +33,7 @@ class CommentRepo
$comment->parent_id = $parent_id; $comment->parent_id = $parent_id;
$entity->comments()->save($comment); $entity->comments()->save($comment);
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
ActivityService::add(ActivityType::COMMENTED_ON, $entity); ActivityService::add(ActivityType::COMMENTED_ON, $entity);
return $comment; return $comment;
@ -60,6 +49,8 @@ class CommentRepo
$comment->html = $this->commentToHtml($text); $comment->html = $this->commentToHtml($text);
$comment->save(); $comment->save();
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
return $comment; return $comment;
} }
@ -69,6 +60,8 @@ class CommentRepo
public function delete(Comment $comment): void public function delete(Comment $comment): void
{ {
$comment->delete(); $comment->delete();
ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
} }
/** /**
@ -82,7 +75,7 @@ class CommentRepo
'allow_unsafe_links' => false, 'allow_unsafe_links' => false,
]); ]);
return $converter->convertToHtml($commentText); return $converter->convert($commentText);
} }
/** /**
@ -90,9 +83,8 @@ class CommentRepo
*/ */
protected function getNextLocalId(Entity $entity): int protected function getNextLocalId(Entity $entity): int
{ {
/** @var Comment $comment */ $currentMaxId = $entity->comments()->max('local_id');
$comment = $entity->comments(false)->orderBy('local_id', 'desc')->first();
return ($comment->local_id ?? 0) + 1; return $currentMaxId + 1;
} }
} }

View File

@ -1,12 +1,12 @@
<?php <?php
namespace BookStack\Http\Controllers; namespace BookStack\Activity\Controllers;
use BookStack\Actions\Activity; use BookStack\Activity\ActivityType;
use BookStack\Actions\ActivityType; use BookStack\Activity\Models\Activity;
use BookStack\Http\Controller;
use BookStack\Util\SimpleListOptions; use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AuditLogController extends Controller class AuditLogController extends Controller
{ {

View File

@ -1,19 +1,18 @@
<?php <?php
namespace BookStack\Http\Controllers; namespace BookStack\Activity\Controllers;
use BookStack\Actions\CommentRepo; use BookStack\Activity\CommentRepo;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Http\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class CommentController extends Controller class CommentController extends Controller
{ {
protected $commentRepo; public function __construct(
protected CommentRepo $commentRepo
public function __construct(CommentRepo $commentRepo) ) {
{
$this->commentRepo = $commentRepo;
} }
/** /**
@ -42,7 +41,13 @@ class CommentController extends Controller
$this->checkPermission('comment-create-all'); $this->checkPermission('comment-create-all');
$comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id')); $comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
return view('comments.comment', ['comment' => $comment]); return view('comments.comment-branch', [
'readOnly' => false,
'branch' => [
'comment' => $comment,
'children' => [],
]
]);
} }
/** /**
@ -62,7 +67,7 @@ class CommentController extends Controller
$comment = $this->commentRepo->update($comment, $request->get('text')); $comment = $this->commentRepo->update($comment, $request->get('text'));
return view('comments.comment', ['comment' => $comment]); return view('comments.comment', ['comment' => $comment, 'readOnly' => false]);
} }
/** /**

View File

@ -0,0 +1,72 @@
<?php
namespace BookStack\Activity\Controllers;
use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
class FavouriteController extends Controller
{
public function __construct(
protected MixedEntityRequestHelper $entityHelper,
) {
}
/**
* Show a listing of all favourite items for the current user.
*/
public function index(Request $request)
{
$viewCount = 20;
$page = intval($request->get('page', 1));
$favourites = (new 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());
}
}

View File

@ -1,18 +1,17 @@
<?php <?php
namespace BookStack\Http\Controllers; namespace BookStack\Activity\Controllers;
use BookStack\Actions\TagRepo; use BookStack\Activity\TagRepo;
use BookStack\Http\Controller;
use BookStack\Util\SimpleListOptions; use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class TagController extends Controller class TagController extends Controller
{ {
protected TagRepo $tagRepo; public function __construct(
protected TagRepo $tagRepo
public function __construct(TagRepo $tagRepo) ) {
{
$this->tagRepo = $tagRepo;
} }
/** /**

View File

@ -0,0 +1,29 @@
<?php
namespace BookStack\Activity\Controllers;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
class WatchController extends Controller
{
public function update(Request $request, MixedEntityRequestHelper $entityHelper)
{
$this->checkPermission('receive-notifications');
$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());
}
}

View File

@ -1,10 +1,11 @@
<?php <?php
namespace BookStack\Http\Controllers; namespace BookStack\Activity\Controllers;
use BookStack\Actions\ActivityType; use BookStack\Activity\ActivityType;
use BookStack\Actions\Queries\WebhooksAllPaginatedAndSorted; use BookStack\Activity\Models\Webhook;
use BookStack\Actions\Webhook; use BookStack\Activity\Queries\WebhooksAllPaginatedAndSorted;
use BookStack\Http\Controller;
use BookStack\Util\SimpleListOptions; use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request; use Illuminate\Http\Request;

View File

@ -0,0 +1,84 @@
<?php
namespace BookStack\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Webhook;
use BookStack\Activity\Tools\WebhookFormatter;
use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use BookStack\Util\SsrUrlValidator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class DispatchWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
protected Webhook $webhook;
protected User $initiator;
protected int $initiatedTime;
protected array $webhookData;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Webhook $webhook, string $event, Loggable|string $detail)
{
$this->webhook = $webhook;
$this->initiator = user();
$this->initiatedTime = time();
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $event, $this->webhook, $detail, $this->initiator, $this->initiatedTime);
$this->webhookData = $themeResponse ?? WebhookFormatter::getDefault($event, $this->webhook, $detail, $this->initiator, $this->initiatedTime)->format();
}
/**
* Execute the job.
*
* @return void
*/
public function handle(HttpRequestService $http)
{
$lastError = null;
try {
(new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint);
$client = $http->buildClient($this->webhook->timeout, [
'connect_timeout' => 10,
'allow_redirects' => ['strict' => true],
]);
$response = $client->sendRequest($http->jsonRequest('POST', $this->webhook->endpoint, $this->webhookData));
$statusCode = $response->getStatusCode();
if ($statusCode >= 400) {
$lastError = "Response status from endpoint was {$statusCode}";
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$statusCode}");
}
} catch (\Exception $error) {
$lastError = $error->getMessage();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
}
$this->webhook->last_called_at = now();
if ($lastError) {
$this->webhook->last_errored_at = now();
$this->webhook->last_error = $lastError;
}
$this->webhook->save();
}
}

View File

@ -1,12 +1,15 @@
<?php <?php
namespace BookStack\Actions; namespace BookStack\Activity\Models;
use BookStack\Auth\User; use BookStack\App\Model;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Model; use BookStack\Permissions\Models\JointPermission;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/** /**
@ -17,6 +20,8 @@ use Illuminate\Support\Str;
* @property string $entity_type * @property string $entity_type
* @property int $entity_id * @property int $entity_id
* @property int $user_id * @property int $user_id
* @property Carbon $created_at
* @property Carbon $updated_at
*/ */
class Activity extends Model class Activity extends Model
{ {
@ -40,6 +45,12 @@ class Activity extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
}
/** /**
* Returns text from the language files, Looks up by using the activity key. * Returns text from the language files, Looks up by using the activity key.
*/ */

View File

@ -0,0 +1,76 @@
<?php
namespace BookStack\Activity\Models;
use BookStack\App\Model;
use BookStack\Users\Models\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property string $text
* @property string $html
* @property int|null $parent_id - Relates to local_id, not id
* @property int $local_id
* @property string $entity_type
* @property int $entity_id
* @property int $created_by
* @property int $updated_by
*/
class Comment extends Model implements Loggable
{
use HasFactory;
use HasCreatorAndUpdater;
protected $fillable = ['text', 'parent_id'];
protected $appends = ['created', 'updated'];
/**
* Get the entity that this comment belongs to.
*/
public function entity(): MorphTo
{
return $this->morphTo('entity');
}
/**
* Get the parent comment this is in reply to (if existing).
*/
public function parent(): BelongsTo
{
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
->where('entity_type', '=', $this->entity_type)
->where('entity_id', '=', $this->entity_id);
}
/**
* Check if a comment has been updated since creation.
*/
public function isUpdated(): bool
{
return $this->updated_at->timestamp > $this->created_at->timestamp;
}
/**
* Get created date as a relative diff.
*/
public function getCreatedAttribute(): string
{
return $this->created_at->diffForHumans();
}
/**
* Get updated date as a relative diff.
*/
public function getUpdatedAttribute(): string
{
return $this->updated_at->diffForHumans();
}
public function logDescriptor(): string
{
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
}
}

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Interfaces; namespace BookStack\Activity\Models;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;

View File

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

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Interfaces; namespace BookStack\Activity\Models;
interface Loggable interface Loggable
{ {

View File

@ -1,9 +1,11 @@
<?php <?php
namespace BookStack\Actions; namespace BookStack\Activity\Models;
use BookStack\Model; use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
/** /**
@ -27,6 +29,12 @@ class Tag extends Model
return $this->morphTo('entity'); return $this->morphTo('entity');
} }
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('tags.entity_type', '=', 'joint_permissions.entity_type');
}
/** /**
* Get a full URL to start a tag name search for this tag name. * Get a full URL to start a tag name search for this tag name.
*/ */

View File

@ -1,9 +1,10 @@
<?php <?php
namespace BookStack\Actions; namespace BookStack\Activity\Models;
use BookStack\Interfaces\Viewable; use BookStack\App\Model;
use BookStack\Model; use BookStack\Permissions\Models\JointPermission;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
/** /**
@ -28,13 +29,19 @@ class View extends Model
return $this->morphTo(); return $this->morphTo();
} }
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'viewable_id')
->whereColumn('views.viewable_type', '=', 'joint_permissions.entity_type');
}
/** /**
* Increment the current user's view count for the given viewable model. * Increment the current user's view count for the given viewable model.
*/ */
public static function incrementFor(Viewable $viewable): int public static function incrementFor(Viewable $viewable): int
{ {
$user = user(); $user = user();
if (is_null($user) || $user->isDefault()) { if ($user->isGuest()) {
return 0; return 0;
} }
@ -47,12 +54,4 @@ class View extends Model
return $view->views; return $view->views;
} }
/**
* Clear all views from the system.
*/
public static function clearAll()
{
static::query()->truncate();
}
} }

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Interfaces; namespace BookStack\Activity\Models;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;

View File

@ -0,0 +1,45 @@
<?php
namespace BookStack\Activity\Models;
use BookStack\Activity\WatchLevels;
use BookStack\Permissions\Models\JointPermission;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property int $user_id
* @property int $watchable_id
* @property string $watchable_type
* @property int $level
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Watch extends Model
{
protected $guarded = [];
public function watchable(): MorphTo
{
return $this->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;
}
}

View File

@ -1,8 +1,8 @@
<?php <?php
namespace BookStack\Actions; namespace BookStack\Activity\Models;
use BookStack\Interfaces\Loggable; use BookStack\Activity\ActivityType;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BookStack\Actions; namespace BookStack\Activity\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@ -0,0 +1,42 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
use BookStack\Entities\Models\Entity;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User;
abstract class BaseNotificationHandler implements NotificationHandler
{
/**
* @param class-string<BaseActivityNotification> $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();
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('receive-notifications')) {
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
$user->notify(new $notification($detail, $initiator));
}
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\CommentCreationNotification;
use BookStack\Activity\Tools\EntityWatchers;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Page;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Users\Models\User;
class CommentCreationNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
if (!($detail instanceof Comment)) {
throw new \InvalidArgumentException("Detail for comment creation notifications must be a comment");
}
// Main watchers
/** @var Page $page */
$page = $detail->entity;
$watchers = new EntityWatchers($page, WatchLevels::COMMENTS);
$watcherIds = $watchers->getWatcherUserIds();
// Page owner if user preferences allow
if (!$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 && !$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);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User;
interface NotificationHandler
{
/**
* Run this handler.
* Provides the activity, related activity detail/model
* along with the user that triggered the activity.
*/
public function handle(Activity $activity, string|Loggable $detail, User $user): void;
}

View File

@ -0,0 +1,24 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\PageCreationNotification;
use BookStack\Activity\Tools\EntityWatchers;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
class PageCreationNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
if (!($detail instanceof Page)) {
throw new \InvalidArgumentException("Detail for page create notifications must be a page");
}
$watchers = new EntityWatchers($detail, WatchLevels::NEW);
$this->sendNotificationToUserIds(PageCreationNotification::class, $watchers->getWatcherUserIds(), $user, $detail, $detail);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\PageUpdateNotification;
use BookStack\Activity\Tools\EntityWatchers;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\Page;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Users\Models\User;
class PageUpdateNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
if (!($detail instanceof Page)) {
throw new \InvalidArgumentException("Detail for page update notifications must be a page");
}
// Get last update from activity
$lastUpdate = $detail->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 page owner if preferences allow
if (!$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);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
use BookStack\Entities\Models\Entity;
use Illuminate\Contracts\Support\Htmlable;
use Stringable;
/**
* A link to a specific entity in the system, with the text showing its name.
*/
class EntityLinkMessageLine implements Htmlable, Stringable
{
public function __construct(
protected Entity $entity,
protected int $nameLength = 120,
) {
}
public function toHtml(): string
{
return '<a href="' . e($this->entity->getUrl()) . '">' . e($this->entity->getShortName($this->nameLength)) . '</a>';
}
public function __toString(): string
{
return "{$this->entity->getShortName($this->nameLength)} ({$this->entity->getUrl()})";
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
use BookStack\Entities\Models\Entity;
use Illuminate\Contracts\Support\Htmlable;
use Stringable;
/**
* A link to a specific entity in the system, with the text showing its name.
*/
class EntityPathMessageLine implements Htmlable, Stringable
{
/**
* @var EntityLinkMessageLine[]
*/
protected array $entityLinks;
public function __construct(
protected array $entities
) {
$this->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(' &gt; ', $entityHtmls);
}
public function __toString(): string
{
return implode(' > ', $this->entityLinks);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
use Illuminate\Contracts\Support\Htmlable;
use Stringable;
/**
* A line of text with linked text included, intended for use
* in MailMessages. The line should have a ':link' placeholder for
* where the link should be inserted within the line.
*/
class LinkedMailMessageLine implements Htmlable, Stringable
{
public function __construct(
protected string $url,
protected string $line,
protected string $linkText,
) {
}
public function toHtml(): string
{
$link = '<a href="' . e($this->url) . '">' . e($this->linkText) . '</a>';
return str_replace(':link', $link, e($this->line));
}
public function __toString(): string
{
$link = "{$this->linkText} ({$this->url})";
return str_replace(':link', $link, $this->line);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
use Illuminate\Contracts\Support\Htmlable;
use Stringable;
/**
* A bullet point list of content, where the keys of the given list array
* are bolded header elements, and the values follow.
*/
class ListMessageLine implements Htmlable, Stringable
{
public function __construct(
protected array $list
) {
}
public function toHtml(): string
{
$list = [];
foreach ($this->list as $header => $content) {
$list[] = '<strong>' . e($header) . '</strong> ' . e($content);
}
return implode("<br>\n", $list);
}
public function __toString(): string
{
$list = [];
foreach ($this->list as $header => $content) {
$list[] = $header . ' ' . $content;
}
return implode("\n", $list);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\MessageParts\EntityPathMessageLine;
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
use BookStack\App\MailNotification;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Translation\LocaleDefinition;
use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable;
abstract class BaseActivityNotification extends MailNotification
{
use Queueable;
public function __construct(
protected Loggable|string $detail,
protected User $user,
) {
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
'activity_detail' => $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('/preferences/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);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class CommentCreationNotification extends BaseActivityNotification
{
public function toMail(User $notifiable): MailMessage
{
/** @var Comment $comment */
$comment = $this->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') => strip_tags($comment->html),
]);
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));
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class PageCreationNotification extends BaseActivityNotification
{
public function toMail(User $notifiable): MailMessage
{
/** @var Page $page */
$page = $this->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));
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class PageUpdateNotification extends BaseActivityNotification
{
public function toMail(User $notifiable): MailMessage
{
/** @var Page $page */
$page = $this->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));
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace BookStack\Activity\Notifications;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
use BookStack\Users\Models\User;
class NotificationManager
{
/**
* @var class-string<NotificationHandler>[]
*/
protected array $handlers = [];
public function handle(Activity $activity, string|Loggable $detail, User $user): void
{
$activityType = $activity->type;
$handlersToRun = $this->handlers[$activityType] ?? [];
foreach ($handlersToRun as $handlerClass) {
/** @var NotificationHandler $handler */
$handler = new $handlerClass();
$handler->handle($activity, $detail, $user);
}
}
/**
* @param class-string<NotificationHandler> $handlerClass
*/
public function registerHandler(string $activityType, string $handlerClass): void
{
if (!isset($this->handlers[$activityType])) {
$this->handlers[$activityType] = [];
}
if (!in_array($handlerClass, $this->handlers[$activityType])) {
$this->handlers[$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);
}
}

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