Compare commits

..

24 Commits

Author SHA1 Message Date
Dan Brown
3083979855 Added method for using enity ownership in relation queries
It has a large linear-entity-scaling performance impact though.
2023-01-15 17:38:08 +00:00
Dan Brown
55642a33ee Attempted fix of issues, realised new query system is a failure
As part of the permission checking we need to check owner user status.
Upon this, we'd also want to check page draft status (and its
creator/owner).
These, for cross-entity/relation queries would need up to another 4 joins.
The performance/index usage is already questionable here.
2023-01-14 13:50:41 +00:00
Dan Brown
93ba572369 Aligned admin permission check restriction ignore 2023-01-13 22:19:29 +00:00
Dan Brown
a825f27930 Updated additional relation queries to apply permissions correctly 2023-01-13 22:13:31 +00:00
Dan Brown
932e1d7c61 Got entity relation query permission application working
May be issues at points of use though, Added todo for this in code.
Also added extra indexes to collapsed table for better query
performance.
2023-01-13 17:10:20 +00:00
Dan Brown
2f1491c5a4 Split out 'restrictEntityQuery' function components
Also fixed search query issue with abiguous column
2023-01-13 16:07:36 +00:00
Dan Brown
026e9030b9 Reworked userCan permission check to follow defined logic.
Got all current scenario tests passing.
Also fixes own permission which was using the wrong field.
2022-12-23 21:07:49 +00:00
Dan Brown
451e4ac452 Fixed collapsed perm. gen for book sub-items.
Also converted the existing "JointPermission" usage to the new
collapsed permission system.
2022-12-23 14:05:43 +00:00
Dan Brown
7330139555 Created big scary query to apply permissions via new format 2022-12-22 20:32:06 +00:00
Dan Brown
39acbeac68 Started new permission-caching/querying model 2022-12-22 15:09:17 +00:00
Dan Brown
2d9d2bba80 Added additional case thats known to currently fail
Also removed so no-longer-relevant todo/comments.
2022-12-21 17:14:54 +00:00
Dan Brown
adabf06dbe Added more inter-method permissions test cases 2022-12-20 19:10:09 +00:00
Dan Brown
5ffc10e688 Added entity user permission scenarios
Also added definitions for general expected behaviour to readme doc, and
added some entity role inherit scenarios to check they meet expectations.
Currently failing role test but not an issue with test, needs fixing to
app logic.
2022-12-20 15:50:41 +00:00
Dan Brown
6a6f5e4d19 Added a bunch of role content permissions 2022-12-17 19:46:48 +00:00
Dan Brown
491beee93e Added additional entity_role_permission scenario tests 2022-12-17 15:27:09 +00:00
Dan Brown
f844ae0902 Create additional test helper classes
Following recent similar actions done for entities.
Required at this stage to provider better & cleaner helpers
for common user and permission actions to built out permission testing.
2022-12-15 12:29:10 +00:00
Dan Brown
d54ea1b3ed Started more formal permission test case definitions 2022-12-15 11:22:53 +00:00
Dan Brown
e8a8fedfd6 Started aligning permission behaviour across application methods 2022-12-14 18:14:01 +00:00
Dan Brown
60bf838a4a Added joint_user_permissions handling to query system
Some issues exist to resolve though, not in final state.
2022-12-11 22:53:46 +00:00
Dan Brown
0411185fbb Added, and built perm. gen for, joint_user_permissions table 2022-12-11 14:51:53 +00:00
Dan Brown
93cbd3b8aa Improved user-permissions adding ux
- Reset input after user selection.
- Corrected permission row title text for user rows.
2022-12-10 14:48:19 +00:00
Dan Brown
7a269e7689 Added users to permission form interface
Also updated non-joint permission handling to support user permissions.
2022-12-10 14:37:18 +00:00
Dan Brown
f8c4725166 Aligned logic to entity_permission role_id usage change
Now idenitifies fallback using role_id and user_id = null.
Lays some foundations for handling user_id.
2022-12-07 22:07:03 +00:00
Dan Brown
1c53ffc4d1 Updated entity_permissions table for user perms.
As start of user permissions work
2022-12-07 14:57:23 +00:00
1792 changed files with 30155 additions and 63172 deletions

View File

@@ -37,10 +37,8 @@ MAIL_FROM=bookstack@example.com
# SMTP mail options
# These settings can be checked using the "Send a Test Email"
# feature found in the "Settings > Maintenance" area of the system.
# For more detailed documentation on mail options, refer to:
# https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration
MAIL_HOST=localhost
MAIL_PORT=587
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null

View File

@@ -3,10 +3,6 @@
# Each option is shown with it's default value.
# Do not copy this whole file to use as your '.env' file.
# The details here only serve as a quick reference.
# Please refer to the BookStack documentation for full details:
# https://www.bookstackapp.com/docs/
# Application environment
# Can be 'production', 'development', 'testing' or 'demo'
APP_ENV=production
@@ -69,20 +65,20 @@ DB_PASSWORD=database_user_password
# certificate itself (Common Name or Subject Alternative Name).
MYSQL_ATTR_SSL_CA="/path/to/ca.pem"
# Mail configuration
# Refer to https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration
# Mail system to use
# Can be 'smtp' or 'sendmail'
MAIL_DRIVER=smtp
MAIL_FROM=bookstack@example.com
# Mail sending options
MAIL_FROM=mail@bookstackapp.com
MAIL_FROM_NAME=BookStack
# SMTP mail options
MAIL_HOST=localhost
MAIL_PORT=587
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_VERIFY_SSL=true
MAIL_SENDMAIL_COMMAND="/usr/sbin/sendmail -bs"
# Cache & Session driver to use
# Can be 'file', 'database', 'memcached' or 'redis'
@@ -215,11 +211,10 @@ LDAP_SERVER=false
LDAP_BASE_DN=false
LDAP_DN=false
LDAP_PASS=false
LDAP_USER_FILTER="(&(uid={user}))"
LDAP_USER_FILTER=false
LDAP_VERSION=false
LDAP_START_TLS=false
LDAP_TLS_INSECURE=false
LDAP_TLS_CA_CERT=false
LDAP_ID_ATTRIBUTE=uid
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
@@ -268,14 +263,11 @@ OIDC_ISSUER_DISCOVER=false
OIDC_PUBLIC_KEY=null
OIDC_AUTH_ENDPOINT=null
OIDC_TOKEN_ENDPOINT=null
OIDC_USERINFO_ENDPOINT=null
OIDC_ADDITIONAL_SCOPES=null
OIDC_DUMP_USER_DETAILS=false
OIDC_USER_TO_GROUPS=false
OIDC_GROUPS_CLAIM=groups
OIDC_REMOVE_FROM_GROUPS=false
OIDC_EXTERNAL_ID_CLAIM=sub
OIDC_END_SESSION_ENDPOINT=false
# Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option
@@ -326,21 +318,6 @@ FILE_UPLOAD_SIZE_LIMIT=50
# Can be 'a4' or 'letter'.
EXPORT_PAGE_SIZE=a4
# Export PDF Command
# Set a command which can be used to convert a HTML file into a PDF file.
# When false this will not be used.
# String values represent the command to be called for conversion.
# Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
EXPORT_PDF_COMMAND=false
# Set path to wkhtmltopdf binary for PDF generation.
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
# When false, BookStack will attempt to find a wkhtmltopdf in the application
# root folder then fall back to the default dompdf renderer if no binary exists.
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
WKHTMLTOPDF=false
# Allow <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false
@@ -370,15 +347,6 @@ ALLOWED_IFRAME_HOSTS=null
# Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com"
# A list of the sources/hostnames that can be reached by application SSR calls.
# This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
# Host-specific functionality (usually controlled via other options) like auth
# or user avatars for example, won't use this list.
# Space seperated if multiple. Can use '*' as a wildcard.
# Values will be compared prefix-matched, case-insensitive, against called SSR urls.
# Defaults to allow all hosts.
ALLOWED_SSR_HOSTS="*"
# The default and maximum item-counts for listing API requests.
API_DEFAULT_ITEM_COUNT=100
API_MAX_ITEM_COUNT=500
@@ -400,4 +368,4 @@ LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
# IP address '146.191.42.4' would result in '146.191.x.x' being logged.
# For the IPv6 address '2001:db8:85a3:8d3:1319:8a2e:370:7348' this would result as:
# '2001:db8:85a3:8d3:x:x:x:x'
IP_ADDRESS_PRECISION=4
IP_ADDRESS_PRECISION=4

View File

@@ -1,14 +1,7 @@
name: Bug Report
description: Create a report to help us fix bugs & issues in existing supported functionality
description: Create a report to help us improve or fix things
labels: [":bug: Bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out a bug report!
Please note that this form is for reporting bugs in existing supported functionality.
If you are reporting something that's not an issue in functionality we've previously supported and/or is simply something different to your expectations, then it may be more appropriate to raise via a feature or support request instead.
- type: textarea
id: description
attributes:
@@ -20,7 +13,7 @@ body:
id: reproduction
attributes:
label: Steps to Reproduce
description: Detail the steps that would replicate this issue.
description: Detail the steps that would replicate this issue
placeholder: |
1. Go to '...'
2. Click on '....'
@@ -39,7 +32,7 @@ body:
id: context
attributes:
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:
required: false
- type: input
@@ -55,7 +48,23 @@ body:
id: bsversion
attributes:
label: Exact BookStack Version
description: This can be found in the settings view of BookStack. Please provide an exact version(s) you've tested on.
placeholder: (eg. v23.06.7)
description: This can be found in the settings view of BookStack. Please provide an exact version.
placeholder: (eg. v21.08.5)
validations:
required: true
- type: input
id: phpversion
attributes:
label: PHP Version
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that relevant to the issue.
placeholder: (eg. 7.4)
validations:
required: false
- type: textarea
id: hosting
attributes:
label: Hosting Environment
description: Describe your hosting environment as much as possible including any proxies used (If applicable).
placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
validations:
required: true

View File

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

View File

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

15
.github/SECURITY.md vendored
View File

@@ -15,13 +15,18 @@ 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)
feel free to raise it via a standard GitHub bug report issue.
If the issue could have a security impact to BookStack instances,
please directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown).
You will need to log in to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
Alternatively you can send a DM via Mastodon to [@danb@fosstodon.org](https://fosstodon.org/@danb).
If the issue could have a security impact to BookStack instances, please use one of the below
methods to report the vulnerability:
- Directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown).
- You will need to login to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
- Alternatively you can send a DM via Twitter to [@ssddanbrown](https://twitter.com/ssddanbrown).
- [Disclose via huntr.dev](https://huntr.dev/bounties/disclose)
- Bounties may be available to you through this platform.
- Be sure to use `https://github.com/BookStackApp/BookStack` as the repository URL.
Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability
can often take a little time due to the amount of preparation required, to ensure the vulnerability has
been covered, and to create the content required to adequately notify the user-base.
Thank you for keeping BookStack instances safe!
Thank you for keeping BookStack instances safe!

View File

@@ -57,7 +57,6 @@ Name :: Languages
@Jokuna :: Korean
@smartshogu :: German; German Informal
@samadha56 :: Persian
@mrmuminov :: Uzbek
cipi1965 :: Italian
Mykola Ronik (Mantikor) :: Ukrainian
furkanoyk :: Turkish
@@ -177,7 +176,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
REMOVED_USER :: ; Dutch; Turkish
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -270,7 +269,7 @@ mcgong (GongMingCai) :: Chinese Simplified; Chinese Traditional
Nanang Setia Budi (sefidananang) :: Indonesian
Андрей Павлов (andrei.pavlov) :: Russian
Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian
Jihyeon Gim (PotatoGim) :: Korean
Ji-Hyeon Gim (PotatoGim) :: Korean
Mihai Ochian (soulstorm19) :: Romanian
HeartCore :: German Informal; German
simon.pct :: French
@@ -284,13 +283,13 @@ Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
digilady :: Greek
Linus (LinusOP) :: Swedish
Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian
RandomUser0815 :: German Informal; German
RandomUser0815 :: German
Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
구인회 (laskdjlaskdj12) :: Korean
LiZerui (CNLiZerui) :: Chinese Traditional
Fabrice Boyer (FabriceBoyer) :: French
mikael (bitcanon) :: Swedish
Matthias Mai (schnapsidee) :: German Informal; German
Matthias Mai (schnapsidee) :: German
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
Jan Mitrof (jan.kachlik) :: Czech
edwardsmirnov :: Russian
@@ -299,133 +298,3 @@ shotu :: French
Cesar_Lopez_Aguillon :: Spanish
bdewoop :: German
dina davoudi (dina.davoudi) :: Persian
Angelos Chouvardas (achouvardas) :: Greek
rndrss :: Portuguese, Brazilian
rirac294 :: Russian
David Furman (thefourCraft) :: Hebrew
Pafzedog :: French
Yllelder :: Spanish
Adrian Ocneanu (aocneanu) :: Romanian
Eduardo Castanho (EduardoCastanho) :: Portuguese
VIET NAM VPS (vietnamvps) :: Vietnamese
m4tthi4s :: French
toras9000 :: Japanese
pathab :: German
MichelSchoon85 :: Dutch
Jøran Haugli (haugli92) :: Norwegian Bokmal
Vasileios Kouvelis (VasilisKouvelis) :: Greek
Dremski :: Bulgarian
Frédéric SENE (nothingfr) :: French
bendem :: French
kostasdizas :: Greek
Ricardo Schroeder (brownstone666) :: Portuguese, Brazilian
Eitan MG (EitanMG) :: Hebrew
Robin Flikkema (RobinFlikkema) :: Dutch
Michal Gurcik (mgurcik) :: Slovak
Pooyan Arab (pooyanarab) :: Persian
Ochi Darma Putra (troke12) :: Indonesian
Hsin-Hsiang Peng (Hsins) :: Chinese Traditional
Mosi Wang (mosiwang) :: Chinese Traditional
骆言 (LawssssCat) :: Chinese Simplified
Stickers Gaming Shøw (StickerSGSHOW) :: French
Le Van Chinh (Chino) (lvanchinh86) :: Vietnamese
Rubens nagios (rubenix) :: Catalan
Patrick Dantas (pa-tiq) :: Portuguese, Brazilian
Michal (michalgurcik) :: Slovak
Nepomacs :: German
Rubens (rubenix) :: Catalan
m4z :: German; German Informal
TheRazvy :: Romanian
Yossi Zilber (lortens) :: Hebrew; Uzbek
desdinova :: French
Ingus Rūķis (ingus.rukis) :: Latvian
Eugene Pershin (SilentEugene) :: Russian
周盛道 (zhoushengdao) :: Chinese Simplified
hamidreza amini (hamidrezaamini2022) :: Persian
Tomislav Kraljević (tomislav.kraljevic) :: Croatian
Taygun Yıldırım (yildirimtaygun) :: Turkish
robing29 :: German
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
Igor V Belousov (biv) :: Russian
David Bauer (davbauer) :: German; German Informal
Guttorm Hveem (guttormhveem) :: Norwegian Nynorsk; Norwegian Bokmal
Minh Giang Truong (minhgiang1204) :: Vietnamese
Ioannis Ioannides (i.ioannides) :: Greek
Vadim (vadrozh) :: Russian
Flip333 :: German Informal; German
Paulo Henrique (paulohsantos114) :: Portuguese, Brazilian
Dženan (Dzenan) :: Swedish
Péter Péli (peter.peli) :: Hungarian
TWME :: Chinese Traditional
Sascha (Man-in-Black) :: German; German Informal
Mohammadreza Madadi (madadi.efl) :: Persian
Konstantin (kkovacheli) :: Ukrainian; Russian
link1183 :: French
Renan (rfpe) :: Portuguese, Brazilian
Lowkey (bbsweb) :: Chinese Simplified
ZZnOB (zznobzz) :: Russian
rupus :: Swedish
developernecsys :: Norwegian Nynorsk
xuan LI (xuanli233) :: Chinese Simplified
LameeQS :: Latvian
Sorin T. (trimbitassorin) :: Romanian
poesty :: Chinese Simplified
balmag :: Hungarian
Antti-Jussi Nygård (ajnyga) :: Finnish
Eduard Ereza Martínez (Ereza) :: Catalan
Jabir Lang (amar.almrad) :: Arabic
Jaroslav Kobližek (foretix) :: Czech; French
Wiktor Adamczyk (adamczyk.wiktor) :: Polish
Abdulmajeed Alshuaibi (4Majeed) :: Arabic
NotSmartZakk :: Czech
HyoungMin Lee (ddokkaebi) :: Korean
Dasferco :: Chinese Simplified
Marcus Teräs (mteras) :: Finnish
Serkan Yardim (serkanzz) :: Turkish
Y (cnsr) :: Ukrainian
ZY ZV (vy0b0x) :: Chinese Simplified
diegobenitez :: Spanish
Marc Hagen (MarcHagen) :: Dutch
Kasper Alsøe (zeonos) :: Danish
sultani :: Persian
renge :: Korean
Tim (thegatesdev) :: Dutch; German Informal; French; Romanian; Catalan; Czech; Danish; German; Finnish; Hungarian; Italian; Japanese; Korean; Polish; Russian; Ukrainian; Chinese Simplified; Chinese Traditional; Portuguese, Brazilian; Persian; Spanish, Argentina; Croatian; Norwegian Nynorsk; Estonian; Uzbek; Norwegian Bokmal
Irdi (irdiOL) :: Albanian
KateBarber :: Welsh
Twister (theuncles75) :: Hebrew
algernon19 :: Hungarian
Ivan Krstic (ikrstic) :: Serbian (Cyrillic)
Show :: Russian
xBahamut :: Portuguese, Brazilian
Pavle Knežević (pavleknezzevic) :: Serbian (Cyrillic)
Vanja Cvelbar (b100w11) :: Slovenian
simonpct :: French
Honza Nagy (honza.nagy) :: Czech
asd20752 :: Norwegian Bokmal
Jan Picka (polipones) :: Czech
diogoalex991 :: Portuguese
Ehsan Sadeghi (ehsansadeghi) :: Persian
ka_picit :: Danish
cracrayol :: French
CapuaSC :: Dutch
Guardian75 :: German Informal
mr-kanister :: German
Michele Bastianelli (makoblaster) :: Italian
jespernissen :: Danish
Andrey (avmaksimov) :: Russian
Gonzalo Loyola (AlFcl) :: Spanish, Argentina; Spanish
grobert63 :: French
wusst. (Supporti) :: German
MaximMaximS :: Czech
damian-klima :: Slovak
crow_ :: Latvian
JocelynDelalande :: French
Jan (JW-CH) :: German Informal
Timo B (lommes) :: German Informal
Erik Lundstedt (Erik.Lundstedt) :: Swedish
yngams (younessmouhid) :: Arabic
Ohadp :: Hebrew
cbridi :: Portuguese, Brazilian
nanangsb :: Indonesian
Michal Melich (michalmelich) :: Czech
David (david-prv) :: German

View File

@@ -1,12 +1,6 @@
name: analyse-php
on:
push:
paths:
- '**.php'
pull_request:
paths:
- '**.php'
on: [push, pull_request]
jobs:
build:
@@ -18,7 +12,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.3
php-version: 8.1
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
@@ -27,10 +21,10 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-8.3
key: ${{ runner.os }}-composer-8.1
restore-keys: ${{ runner.os }}-composer-
- name: Install composer dependencies

View File

@@ -1,24 +0,0 @@
name: lint-js
on:
push:
paths:
- '**.js'
- '**.json'
pull_request:
paths:
- '**.js'
- '**.json'
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-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,12 +1,6 @@
name: lint-php
on:
push:
paths:
- '**.php'
pull_request:
paths:
- '**.php'
on: [push, pull_request]
jobs:
build:

View File

@@ -1,14 +1,6 @@
name: test-migrations
on:
push:
paths:
- '**.php'
- 'composer.*'
pull_request:
paths:
- '**.php'
- 'composer.*'
on: [push, pull_request]
jobs:
build:
@@ -16,7 +8,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['8.1', '8.2', '8.3']
php: ['7.4', '8.0', '8.1', '8.2']
steps:
- uses: actions/checkout@v1
@@ -32,7 +24,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}

View File

@@ -1,14 +1,6 @@
name: test-php
on:
push:
paths:
- '**.php'
- 'composer.*'
pull_request:
paths:
- '**.php'
- 'composer.*'
on: [push, pull_request]
jobs:
build:
@@ -16,7 +8,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['8.1', '8.2', '8.3']
php: ['7.4', '8.0', '8.1', '8.2']
steps:
- uses: actions/checkout@v1
@@ -24,7 +16,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
id: composer-cache
@@ -32,7 +24,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}

14
.gitignore vendored
View File

@@ -1,19 +1,16 @@
/vendor
/node_modules
/.vscode
/composer
Homestead.yaml
.env
.idea
npm-debug.log
yarn-error.log
/public/dist/*.map
/public/dist
/public/plugins
/public/css/*.map
/public/js/*.map
/public/css
/public/js
/public/bower
/public/build/
/public/favicon.ico
/storage/images
_ide_helper.php
/storage/debugbar
@@ -23,11 +20,8 @@ yarn.lock
nbproject
.buildpath
.project
.nvmrc
.settings/
webpack-stats.json
.phpunit.result.cache
.DS_Store
phpstan.neon
esbuild-meta.json
.phpactor.json
phpstan.neon

View File

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

View File

@@ -1,110 +0,0 @@
<?php
namespace BookStack\Access;
/**
* Class Ldap
* An object-orientated thin abstraction wrapper for common PHP LDAP functions.
* Allows the standard LDAP functions to be mocked for testing.
*/
class Ldap
{
/**
* Connect to an LDAP server.
*
* @return resource|\LDAP\Connection|false
*/
public function connect(string $hostName)
{
return ldap_connect($hostName);
}
/**
* Set the value of an LDAP option for the given connection.
*
* @param resource|\LDAP\Connection|null $ldapConnection
*/
public function setOption($ldapConnection, int $option, mixed $value): bool
{
return ldap_set_option($ldapConnection, $option, $value);
}
/**
* Start TLS on the given LDAP connection.
*/
public function startTls($ldapConnection): bool
{
return ldap_start_tls($ldapConnection);
}
/**
* Set the version number for the given LDAP connection.
*
* @param resource|\LDAP\Connection $ldapConnection
*/
public function setVersion($ldapConnection, int $version): bool
{
return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
}
/**
* Search LDAP tree using the provided filter.
*
* @param resource|\LDAP\Connection $ldapConnection
*
* @return 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,27 +0,0 @@
<?php
namespace BookStack\Access\Mfa;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class TotpValidationRule implements ValidationRule
{
/**
* Create a new rule instance.
* Takes the TOTP secret that must be system provided, not user provided.
*/
public function __construct(
protected string $secret,
protected TotpService $totpService,
) {
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$passes = $this->totpService->verifyCode($value, $this->secret);
if (!$passes) {
$fail(trans('validation.totp'));
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,89 +0,0 @@
<?php
namespace BookStack\Access\Oidc;
class OidcIdToken extends OidcJwtWithClaims implements ProvidesClaims
{
/**
* Validate all possible parts of the id token.
*
* @throws OidcInvalidTokenException
*/
public function validate(string $clientId): bool
{
parent::validateCommonTokenDetails($clientId);
$this->validateTokenClaims($clientId);
return true;
}
/**
* Validate the claims of the token.
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenClaims(string $clientId): void
{
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
// MUST exactly match the value of the iss (issuer) Claim.
// Already done in parent.
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
// if the ID Token does not list the Client as a valid audience, or if it contains additional
// audiences not trusted by the Client.
// Partially done in parent.
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
if (count($aud) !== 1) {
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
}
// 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
// NOTE: Addressed by enforcing a count of 1 above.
// 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id
// is the Claim Value.
if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) {
throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id');
}
// 5. The current time MUST be before the time represented by the exp Claim
// (possibly allowing for some small leeway to account for clock skew).
if (empty($this->payload['exp'])) {
throw new OidcInvalidTokenException('Missing token expiration time value');
}
$skewSeconds = 120;
$now = time();
if ($now >= (intval($this->payload['exp']) + $skewSeconds)) {
throw new OidcInvalidTokenException('Token has expired');
}
// 6. The iat Claim can be used to reject tokens that were issued too far away from the current time,
// limiting the amount of time that nonces need to be stored to prevent attacks.
// The acceptable range is Client specific.
if (empty($this->payload['iat'])) {
throw new OidcInvalidTokenException('Missing token issued at time value');
}
$dayAgo = time() - 86400;
$iat = intval($this->payload['iat']);
if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) {
throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid');
}
// 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate.
// The meaning and processing of acr Claim Values is out of scope for this document.
// NOTE: Not used for our case here. acr is not requested.
// 8. When a max_age request is made, the Client SHOULD check the auth_time Claim value and request
// re-authentication if it determines too much time has elapsed since the last End-User authentication.
// NOTE: Not used for our case here. A max_age request is not made.
// Custom: Ensure the "sub" (Subject) Claim exists and has a value.
if (empty($this->payload['sub'])) {
throw new OidcInvalidTokenException('Missing token subject value');
}
}
}

View File

@@ -1,75 +0,0 @@
<?php
namespace BookStack\Access\Oidc;
use Illuminate\Support\Arr;
class OidcUserDetails
{
public function __construct(
public ?string $externalId = null,
public ?string $email = null,
public ?string $name = null,
public ?array $groups = null,
) {
}
/**
* Check if the user details are fully populated for our usage.
*/
public function isFullyPopulated(bool $groupSyncActive): bool
{
$hasEmpty = empty($this->externalId)
|| empty($this->email)
|| empty($this->name)
|| ($groupSyncActive && empty($this->groups));
return !$hasEmpty;
}
/**
* Populate user details from the given claim data.
*/
public function populate(
ProvidesClaims $claims,
string $idClaim,
string $displayNameClaims,
string $groupsClaim,
): void {
$this->externalId = $claims->getClaim($idClaim) ?? $this->externalId;
$this->email = $claims->getClaim('email') ?? $this->email;
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
}
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $token): string
{
$displayNameClaimParts = explode('|', $displayNameClaims);
$displayName = [];
foreach ($displayNameClaimParts as $claim) {
$component = $token->getClaim(trim($claim)) ?? '';
if ($component !== '') {
$displayName[] = $component;
}
}
return implode(' ', $displayName);
}
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): array
{
if (empty($groupsClaim)) {
return [];
}
$groupsList = Arr::get($token->getAllClaims(), $groupsClaim);
if (!is_array($groupsList)) {
return [];
}
return array_values(array_filter($groupsList, function ($val) {
return is_string($val);
}));
}
}

View File

@@ -1,67 +0,0 @@
<?php
namespace BookStack\Access\Oidc;
use Psr\Http\Message\ResponseInterface;
class OidcUserinfoResponse implements ProvidesClaims
{
protected array $claims = [];
protected ?OidcJwtWithClaims $jwt = null;
public function __construct(ResponseInterface $response, string $issuer, array $keys)
{
$contentType = $response->getHeader('Content-Type')[0];
if ($contentType === 'application/json') {
$this->claims = json_decode($response->getBody()->getContents(), true);
}
if ($contentType === 'application/jwt') {
$this->jwt = new OidcJwtWithClaims($response->getBody()->getContents(), $issuer, $keys);
$this->claims = $this->jwt->getAllClaims();
}
}
/**
* @throws OidcInvalidTokenException
*/
public function validate(string $idTokenSub, string $clientId): bool
{
if (!is_null($this->jwt)) {
$this->jwt->validateCommonTokenDetails($clientId);
}
$sub = $this->getClaim('sub');
// Spec: v1.0 5.3.2: The sub (subject) Claim MUST always be returned in the UserInfo Response.
if (!is_string($sub) || empty($sub)) {
throw new OidcInvalidTokenException("No valid subject value found in userinfo data");
}
// Spec: v1.0 5.3.2: The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token;
// if they do not match, the UserInfo Response values MUST NOT be used.
if ($idTokenSub !== $sub) {
throw new OidcInvalidTokenException("Subject value provided in the userinfo endpoint does not match the provided ID token value");
}
// Spec v1.0 5.3.4 Defines the following:
// Verify that the OP that responded was the intended OP through a TLS server certificate check, per RFC 6125 [RFC6125].
// This is effectively done as part of the HTTP request we're making through CURLOPT_SSL_VERIFYHOST on the request.
// If the Client has provided a userinfo_encrypted_response_alg parameter during Registration, decrypt the UserInfo Response using the keys specified during Registration.
// We don't currently support JWT encryption for OIDC
// If the response was signed, the Client SHOULD validate the signature according to JWS [JWS].
// This is done as part of the validateCommonClaims above.
return true;
}
public function getClaim(string $claim): mixed
{
return $this->claims[$claim] ?? null;
}
public function getAllClaims(): array
{
return $this->claims;
}
}

View File

@@ -1,17 +0,0 @@
<?php
namespace BookStack\Access\Oidc;
interface ProvidesClaims
{
/**
* Fetch a specific claim.
* Returns null if it is null or does not exist.
*/
public function getClaim(string $claim): mixed;
/**
* Get all contained claims.
*/
public function getAllClaims(): array;
}

View File

@@ -1,147 +0,0 @@
<?php
namespace BookStack\Access;
use BookStack\Exceptions\SocialDriverNotConfigured;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use SocialiteProviders\Manager\SocialiteWasCalled;
class SocialDriverManager
{
/**
* The default built-in social drivers we support.
*
* @var string[]
*/
protected array $validDrivers = [
'google',
'github',
'facebook',
'slack',
'twitter',
'azure',
'okta',
'gitlab',
'twitch',
'discord',
];
/**
* Callbacks to run when configuring a social driver
* for an initial redirect action.
* Array is keyed by social driver name.
* Callbacks are passed an instance of the driver.
*
* @var array<string, callable>
*/
protected array $configureForRedirectCallbacks = [];
/**
* Check if the current config for the given driver allows auto-registration.
*/
public function isAutoRegisterEnabled(string $driver): bool
{
return $this->getDriverConfigProperty($driver, 'auto_register') === true;
}
/**
* Check if the current config for the given driver allow email address auto-confirmation.
*/
public function isAutoConfirmEmailEnabled(string $driver): bool
{
return $this->getDriverConfigProperty($driver, 'auto_confirm') === true;
}
/**
* Gets the names of the active social drivers, keyed by driver id.
* @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,38 +1,35 @@
<?php
namespace BookStack\Activity\Models;
namespace BookStack\Actions;
use BookStack\App\Model;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Permissions\Models\JointPermission;
use BookStack\Users\Models\User;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* @property string $type
* @property User $user
* @property Entity $loggable
* @property Entity $entity
* @property string $detail
* @property string $loggable_type
* @property int $loggable_id
* @property string $entity_type
* @property int $entity_id
* @property int $user_id
* @property Carbon $created_at
*/
class Activity extends Model
{
/**
* Get the loggable model related to this activity.
* Currently only used for entities (previously entity_[id/type] columns).
* Could be used for others but will need an audit of uses where assumed
* to be entities.
* Get the entity for this activity.
*/
public function loggable(): MorphTo
public function entity(): MorphTo
{
return $this->morphTo('loggable');
if ($this->entity_type === '') {
$this->entity_type = null;
}
return $this->morphTo('entity');
}
/**
@@ -43,12 +40,6 @@ class Activity extends Model
return $this->belongsTo(User::class);
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'loggable_id')
->whereColumn('activities.loggable_type', '=', 'joint_permissions.entity_type');
}
/**
* Returns text from the language files, Looks up by using the activity key.
*/
@@ -72,6 +63,6 @@ class Activity extends Model
*/
public function isSimilarTo(self $activityB): bool
{
return [$this->type, $this->loggable_type, $this->loggable_id] === [$activityB->type, $activityB->loggable_type, $activityB->loggable_id];
return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
}
}

View File

@@ -1,30 +1,22 @@
<?php
namespace BookStack\Activity\Tools;
namespace BookStack\Actions;
use BookStack\Activity\DispatchWebhookJob;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Webhook;
use BookStack\Activity\Notifications\NotificationManager;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Theme;
use BookStack\Interfaces\Loggable;
use BookStack\Theming\ThemeEvents;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
class ActivityLogger
{
public function __construct(
protected NotificationManager $notifications
) {
$this->notifications->loadDefaultHandlers();
}
/**
* Add a generic activity event to the database.
*
* @param string|Loggable $detail
*/
public function add(string $type, string|Loggable $detail = ''): void
public function add(string $type, $detail = '')
{
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
@@ -32,15 +24,14 @@ class ActivityLogger
$activity->detail = $detailToStore;
if ($detail instanceof Entity) {
$activity->loggable_id = $detail->id;
$activity->loggable_type = $detail->getMorphClass();
$activity->entity_id = $detail->id;
$activity->entity_type = $detail->getMorphClass();
}
$activity->save();
$this->setNotification($type);
$this->dispatchWebhooks($type, $detail);
$this->notifications->handle($activity, $detail, user());
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
}
@@ -61,12 +52,12 @@ class ActivityLogger
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
*/
public function removeEntity(Entity $entity): void
public function removeEntity(Entity $entity)
{
$entity->activity()->update([
'detail' => $entity->name,
'loggable_id' => null,
'loggable_type' => null,
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
]);
}
@@ -82,7 +73,10 @@ class ActivityLogger
}
}
protected function dispatchWebhooks(string $type, string|Loggable $detail): void
/**
* @param string|Loggable $detail
*/
protected function dispatchWebhooks(string $type, $detail): void
{
$webhooks = Webhook::query()
->whereHas('trackedEvents', function (Builder $query) use ($type) {
@@ -101,7 +95,7 @@ class ActivityLogger
* Log out a failed login attempt, Providing the given username
* as part of the message if the '%u' string is used.
*/
public function logFailedLogin(string $username): void
public function logFailedLogin(string $username)
{
$message = config('logging.failed_login.message');
if (!$message) {

View File

@@ -1,24 +1,25 @@
<?php
namespace BookStack\Activity;
namespace BookStack\Actions;
use BookStack\Activity\Models\Activity;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
class ActivityQueries
{
public function __construct(
protected PermissionApplicator $permissions,
protected MixedEntityListLoader $listLoader,
) {
protected PermissionApplicator $permissions;
protected array $fieldsForLists = ['id', 'type', 'detail', 'activities.entity_type', 'activities.entity_id', 'user_id', 'created_at'];
public function __construct(PermissionApplicator $permissions)
{
$this->permissions = $permissions;
}
/**
@@ -26,16 +27,16 @@ class ActivityQueries
*/
public function latest(int $count = 20, int $page = 0): array
{
$query = Activity::query()->select($this->fieldsForLists);
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
->restrictEntityRelationQuery($query, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['user'])
->whereNotNull('activities.entity_id')
->with(['user', 'entity'])
->skip($count * $page)
->take($count)
->get();
$this->listLoader->loadIntoRelations($activityList->all(), 'loggable', false);
return $this->filterSimilar($activityList);
}
@@ -59,14 +60,14 @@ class ActivityQueries
$query->where(function (Builder $query) use ($queryIds) {
foreach ($queryIds as $morphClass => $idArr) {
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
$innerQuery->where('loggable_type', '=', $morphClass)
->whereIn('loggable_id', $idArr);
$innerQuery->where('entity_type', '=', $morphClass)
->whereIn('entity_id', $idArr);
});
}
});
$activity = $query->orderBy('created_at', 'desc')
->with(['loggable' => function (Relation $query) {
->with(['entity' => function (Relation $query) {
$query->withTrashed();
}, 'user.avatar'])
->skip($count * ($page - 1))
@@ -81,10 +82,12 @@ class ActivityQueries
*/
public function userActivity(User $user, int $count = 20, int $page = 0): array
{
$query = Activity::query()->select($this->fieldsForLists);
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
->restrictEntityRelationQuery($query, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->whereNotNull('activities.entity_id')
->skip($count * $page)
->take($count)
->get();

View File

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

60
app/Actions/Comment.php Normal file
View File

@@ -0,0 +1,60 @@
<?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

@@ -0,0 +1,98 @@
<?php
namespace BookStack\Actions;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Activity as ActivityService;
use League\CommonMark\CommonMarkConverter;
/**
* Class CommentRepo.
*/
class CommentRepo
{
/**
* @var Comment
*/
protected $comment;
public function __construct(Comment $comment)
{
$this->comment = $comment;
}
/**
* Get a comment by ID.
*/
public function getById(int $id): Comment
{
return $this->comment->newQuery()->findOrFail($id);
}
/**
* Create a new comment on an entity.
*/
public function create(Entity $entity, string $text, ?int $parent_id): Comment
{
$userId = user()->id;
$comment = $this->comment->newInstance();
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
$comment->created_by = $userId;
$comment->updated_by = $userId;
$comment->local_id = $this->getNextLocalId($entity);
$comment->parent_id = $parent_id;
$entity->comments()->save($comment);
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
return $comment;
}
/**
* Update an existing comment.
*/
public function update(Comment $comment, string $text): Comment
{
$comment->updated_by = user()->id;
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
$comment->save();
return $comment;
}
/**
* Delete a comment from the system.
*/
public function delete(Comment $comment): void
{
$comment->delete();
}
/**
* Convert the given comment Markdown to HTML.
*/
public function commentToHtml(string $commentText): string
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'max_nesting_level' => 10,
'allow_unsafe_links' => false,
]);
return $converter->convertToHtml($commentText);
}
/**
* Get the next local ID relative to the linked entity.
*/
protected function getNextLocalId(Entity $entity): int
{
/** @var Comment $comment */
$comment = $entity->comments(false)->orderBy('local_id', 'desc')->first();
return ($comment->local_id ?? 0) + 1;
}
}

View File

@@ -0,0 +1,82 @@
<?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();
}
}

19
app/Actions/Favourite.php Normal file
View File

@@ -0,0 +1,19 @@
<?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,6 +1,6 @@
<?php
namespace BookStack\Activity\Tools;
namespace BookStack\Actions;
class IpFormatter
{

View File

@@ -1,8 +1,8 @@
<?php
namespace BookStack\Activity\Queries;
namespace BookStack\Actions\Queries;
use BookStack\Activity\Models\Webhook;
use BookStack\Actions\Webhook;
use BookStack\Util\SimpleListOptions;
use Illuminate\Pagination\LengthAwarePaginator;

View File

@@ -1,11 +1,9 @@
<?php
namespace BookStack\Activity\Models;
namespace BookStack\Actions;
use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission;
use BookStack\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
@@ -29,12 +27,6 @@ class Tag extends Model
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.
*/

View File

@@ -1,8 +1,6 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Tag;
namespace BookStack\Actions;
class TagClassGenerator
{

View File

@@ -1,10 +1,9 @@
<?php
namespace BookStack\Activity;
namespace BookStack\Actions;
use BookStack\Activity\Models\Tag;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Models\Entity;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Util\SimpleListOptions;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
@@ -12,9 +11,11 @@ use Illuminate\Support\Facades\DB;
class TagRepo
{
public function __construct(
protected PermissionApplicator $permissions
) {
protected PermissionApplicator $permissions;
public function __construct(PermissionApplicator $permissions)
{
$this->permissions = $permissions;
}
/**
@@ -28,18 +29,18 @@ class TagRepo
$sort = 'value';
}
$entityTypeCol = DB::getTablePrefix() . 'tags.entity_type';
$query = Tag::query()
->select([
'name',
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
DB::raw('COUNT(id) as usages'),
DB::raw('SUM(IF(entity_type = \'page\', 1, 0)) as page_count'),
DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'),
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
DB::raw("SUM(IF({$entityTypeCol} = 'page', 1, 0)) as page_count"),
DB::raw("SUM(IF({$entityTypeCol} = 'chapter', 1, 0)) as chapter_count"),
DB::raw("SUM(IF({$entityTypeCol} = 'book', 1, 0)) as book_count"),
DB::raw("SUM(IF({$entityTypeCol} = 'bookshelf', 1, 0)) as shelf_count"),
])
->orderBy($sort, $listOptions->getOrder())
->whereHas('entity');
->orderBy($sort, $listOptions->getOrder());
if ($nameFilter) {
$query->where('name', '=', $nameFilter);
@@ -90,7 +91,6 @@ class TagRepo
{
$query = Tag::query()
->select('*', DB::raw('count(*) as count'))
->where('value', '!=', '')
->groupBy('value');
if ($searchTerm) {

View File

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

View File

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

View File

@@ -1,14 +1,12 @@
<?php
namespace BookStack\Activity\Tools;
namespace BookStack\Actions;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Webhook;
use BookStack\App\Model;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use Illuminate\Support\Carbon;
class WebhookFormatter
@@ -17,14 +15,18 @@ class WebhookFormatter
protected string $event;
protected User $initiator;
protected int $initiatedTime;
protected string|Loggable $detail;
/**
* @var string|Loggable
*/
protected $detail;
/**
* @var array{condition: callable(string, Model):bool, format: callable(Model):void}[]
*/
protected $modelFormatters = [];
public function __construct(string $event, Webhook $webhook, string|Loggable $detail, User $initiator, int $initiatedTime)
public function __construct(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime)
{
$this->webhook = $webhook;
$this->event = $event;

View File

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

View File

@@ -1,74 +0,0 @@
<?php
namespace BookStack\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Activity as ActivityService;
use BookStack\Util\HtmlDescriptionFilter;
class CommentRepo
{
/**
* Get a comment by ID.
*/
public function getById(int $id): Comment
{
return Comment::query()->findOrFail($id);
}
/**
* Create a new comment on an entity.
*/
public function create(Entity $entity, string $html, ?int $parent_id): Comment
{
$userId = user()->id;
$comment = new Comment();
$comment->html = HtmlDescriptionFilter::filterFromString($html);
$comment->created_by = $userId;
$comment->updated_by = $userId;
$comment->local_id = $this->getNextLocalId($entity);
$comment->parent_id = $parent_id;
$entity->comments()->save($comment);
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
return $comment;
}
/**
* Update an existing comment.
*/
public function update(Comment $comment, string $html): Comment
{
$comment->updated_by = user()->id;
$comment->html = HtmlDescriptionFilter::filterFromString($html);
$comment->save();
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
return $comment;
}
/**
* Delete a comment from the system.
*/
public function delete(Comment $comment): void
{
$comment->delete();
ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
}
/**
* Get the next local ID relative to the linked entity.
*/
protected function getNextLocalId(Entity $entity): int
{
$currentMaxId = $entity->comments()->max('local_id');
return $currentMaxId + 1;
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace BookStack\Activity\Controllers;
use BookStack\Activity\Models\Activity;
use BookStack\Http\ApiController;
class AuditLogApiController extends ApiController
{
/**
* Get a listing of audit log events in the system.
* The loggable relation fields currently only relates to core
* content types (page, book, bookshelf, chapter) but this may be
* used more in the future across other types.
* Requires permission to manage both users and system settings.
*/
public function list()
{
$this->checkPermission('settings-manage');
$this->checkPermission('users-manage');
$query = Activity::query()->with(['user']);
return $this->apiListingResponse($query, [
'id', 'type', 'detail', 'user_id', 'loggable_id', 'loggable_type', 'ip', 'created_at',
]);
}
}

View File

@@ -1,72 +0,0 @@
<?php
namespace BookStack\Activity\Controllers;
use BookStack\Entities\Queries\QueryTopFavourites;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
class FavouriteController extends Controller
{
public function __construct(
protected MixedEntityRequestHelper $entityHelper,
) {
}
/**
* Show a listing of all favourite items for the current user.
*/
public function index(Request $request, QueryTopFavourites $topFavourites)
{
$viewCount = 20;
$page = intval($request->get('page', 1));
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
$this->setPageTitle(trans('entities.my_favourites'));
return view('common.detailed-listing-with-more', [
'title' => trans('entities.my_favourites'),
'entities' => $favourites->slice(0, $viewCount),
'hasMoreLink' => $hasMoreLink,
]);
}
/**
* Add a new item as a favourite.
*/
public function add(Request $request)
{
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
$entity->favourites()->firstOrCreate([
'user_id' => user()->id,
]);
$this->showSuccessNotification(trans('activities.favourite_add_notification', [
'name' => $entity->name,
]));
return redirect($entity->getUrl());
}
/**
* Remove an item as a favourite.
*/
public function remove(Request $request)
{
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
$entity->favourites()->where([
'user_id' => user()->id,
])->delete();
$this->showSuccessNotification(trans('activities.favourite_remove_notification', [
'name' => $entity->name,
]));
return redirect($entity->getUrl());
}
}

View File

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

View File

@@ -1,82 +0,0 @@
<?php
namespace BookStack\Activity\Models;
use BookStack\App\Model;
use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Util\HtmlContentFilter;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property string $text - Deprecated & now unused (#4821)
* @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 = ['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})";
}
public function safeHtml(): string
{
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
}
}

View File

@@ -1,27 +0,0 @@
<?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,45 +0,0 @@
<?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,42 +0,0 @@
<?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

@@ -1,48 +0,0 @@
<?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

@@ -1,17 +0,0 @@
<?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

@@ -1,24 +0,0 @@
<?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

@@ -1,51 +0,0 @@
<?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

@@ -1,29 +0,0 @@
<?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

@@ -1,35 +0,0 @@
<?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

@@ -1,33 +0,0 @@
<?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

@@ -1,36 +0,0 @@
<?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

@@ -1,67 +0,0 @@
<?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('/my-account/notifications'),
$locale->trans('notifications.footer_reason'),
$locale->trans('notifications.footer_reason_link'),
);
}
/**
* Build a line which provides the book > chapter path to a page.
* Takes into account visibility of these parent items.
* Returns null if no path items can be used.
*/
protected function buildPagePathLine(Page $page, User $notifiable): ?EntityPathMessageLine
{
$permissions = new PermissionApplicator($notifiable);
$path = array_filter([$page->book, $page->chapter], function (?Entity $entity) use ($permissions) {
return !is_null($entity) && $permissions->checkOwnableUserAccess($entity, 'view');
});
return empty($path) ? null : new EntityPathMessageLine($path);
}
}

View File

@@ -1,37 +0,0 @@
<?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

@@ -1,33 +0,0 @@
<?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

@@ -1,34 +0,0 @@
<?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

@@ -1,52 +0,0 @@
<?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);
}
}

View File

@@ -1,113 +0,0 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Page;
class CommentTree
{
/**
* The built nested tree structure array.
* @var array{comment: Comment, depth: int, children: array}[]
*/
protected array $tree;
protected array $comments;
public function __construct(
protected Page $page
) {
$this->comments = $this->loadComments();
$this->tree = $this->createTree($this->comments);
}
public function enabled(): bool
{
return !setting('app-disable-comments');
}
public function empty(): bool
{
return count($this->tree) === 0;
}
public function count(): int
{
return count($this->comments);
}
public function get(): array
{
return $this->tree;
}
public function canUpdateAny(): bool
{
foreach ($this->comments as $comment) {
if (userCan('comment-update', $comment)) {
return true;
}
}
return false;
}
/**
* @param Comment[] $comments
*/
protected function createTree(array $comments): array
{
$byId = [];
foreach ($comments as $comment) {
$byId[$comment->local_id] = $comment;
}
$childMap = [];
foreach ($comments as $comment) {
$parent = $comment->parent_id;
if (is_null($parent) || !isset($byId[$parent])) {
$parent = 0;
}
if (!isset($childMap[$parent])) {
$childMap[$parent] = [];
}
$childMap[$parent][] = $comment->local_id;
}
$tree = [];
foreach ($childMap[0] ?? [] as $childId) {
$tree[] = $this->createTreeForId($childId, 0, $byId, $childMap);
}
return $tree;
}
protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array
{
$childIds = $childMap[$id] ?? [];
$children = [];
foreach ($childIds as $childId) {
$children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap);
}
return [
'comment' => $byId[$id],
'depth' => $depth,
'children' => $children,
];
}
protected function loadComments(): array
{
if (!$this->enabled()) {
return [];
}
return $this->page->comments()
->with('createdBy')
->get()
->all();
}
}

View File

@@ -1,86 +0,0 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Watch;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Builder;
class EntityWatchers
{
/**
* @var int[]
*/
protected array $watchers = [];
/**
* @var int[]
*/
protected array $ignorers = [];
public function __construct(
protected Entity $entity,
protected int $watchLevel,
) {
$this->build();
}
public function getWatcherUserIds(): array
{
return $this->watchers;
}
public function isUserIgnoring(int $userId): bool
{
return in_array($userId, $this->ignorers);
}
protected function build(): void
{
$watches = $this->getRelevantWatches();
// Sort before de-duping, so that the order looped below follows book -> chapter -> page ordering
usort($watches, function (Watch $watchA, Watch $watchB) {
$entityTypeDiff = $watchA->watchable_type <=> $watchB->watchable_type;
return $entityTypeDiff === 0 ? ($watchA->user_id <=> $watchB->user_id) : $entityTypeDiff;
});
// De-dupe by user id to get their most relevant level
$levelByUserId = [];
foreach ($watches as $watch) {
$levelByUserId[$watch->user_id] = $watch->level;
}
// Populate the class arrays
$this->watchers = array_keys(array_filter($levelByUserId, fn(int $level) => $level >= $this->watchLevel));
$this->ignorers = array_keys(array_filter($levelByUserId, fn(int $level) => $level === 0));
}
/**
* @return Watch[]
*/
protected function getRelevantWatches(): array
{
/** @var Entity[] $entitiesInvolved */
$entitiesInvolved = array_filter([
$this->entity,
$this->entity instanceof BookChild ? $this->entity->book : null,
$this->entity instanceof Page ? $this->entity->chapter : null,
]);
$query = Watch::query()->where(function (Builder $query) use ($entitiesInvolved) {
foreach ($entitiesInvolved as $entity) {
$query->orWhere(function (Builder $query) use ($entity) {
$query->where('watchable_type', '=', $entity->getMorphClass())
->where('watchable_id', '=', $entity->id);
});
}
});
return $query->get([
'level', 'watchable_id', 'watchable_type', 'user_id'
])->all();
}
}

View File

@@ -1,131 +0,0 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Watch;
use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
class UserEntityWatchOptions
{
protected ?array $watchMap = null;
public function __construct(
protected User $user,
protected Entity $entity,
) {
}
public function canWatch(): bool
{
return $this->user->can('receive-notifications') && !$this->user->isGuest();
}
public function getWatchLevel(): string
{
return WatchLevels::levelValueToName($this->getWatchLevelValue());
}
public function isWatching(): bool
{
return $this->getWatchLevelValue() !== WatchLevels::DEFAULT;
}
public function getWatchedParent(): ?WatchedParentDetails
{
$watchMap = $this->getWatchMap();
unset($watchMap[$this->entity->getMorphClass()]);
if (isset($watchMap['chapter'])) {
return new WatchedParentDetails('chapter', $watchMap['chapter']);
}
if (isset($watchMap['book'])) {
return new WatchedParentDetails('book', $watchMap['book']);
}
return null;
}
public function updateLevelByName(string $level): void
{
$levelValue = WatchLevels::levelNameToValue($level);
$this->updateLevelByValue($levelValue);
}
public function updateLevelByValue(int $level): void
{
if ($level < 0) {
$this->remove();
return;
}
$this->updateLevel($level);
}
public function getWatchMap(): array
{
if (!is_null($this->watchMap)) {
return $this->watchMap;
}
$entities = [$this->entity];
if ($this->entity instanceof BookChild) {
$entities[] = $this->entity->book;
}
if ($this->entity instanceof Page && $this->entity->chapter) {
$entities[] = $this->entity->chapter;
}
$query = Watch::query()
->where('user_id', '=', $this->user->id)
->where(function (Builder $subQuery) use ($entities) {
foreach ($entities as $entity) {
$subQuery->orWhere(function (Builder $whereQuery) use ($entity) {
$whereQuery->where('watchable_type', '=', $entity->getMorphClass())
->where('watchable_id', '=', $entity->id);
});
}
});
$this->watchMap = $query->get(['watchable_type', 'level'])
->pluck('level', 'watchable_type')
->toArray();
return $this->watchMap;
}
protected function getWatchLevelValue()
{
return $this->getWatchMap()[$this->entity->getMorphClass()] ?? WatchLevels::DEFAULT;
}
protected function updateLevel(int $levelValue): void
{
Watch::query()->updateOrCreate([
'watchable_id' => $this->entity->id,
'watchable_type' => $this->entity->getMorphClass(),
'user_id' => $this->user->id,
], [
'level' => $levelValue,
]);
$this->watchMap = null;
}
protected function remove(): void
{
$this->entityQuery()->delete();
$this->watchMap = null;
}
protected function entityQuery(): Builder
{
return Watch::query()->where('watchable_id', '=', $this->entity->id)
->where('watchable_type', '=', $this->entity->getMorphClass())
->where('user_id', '=', $this->user->id);
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\WatchLevels;
class WatchedParentDetails
{
public function __construct(
public string $type,
public int $level,
) {
}
public function ignoring(): bool
{
return $this->level === WatchLevels::IGNORE;
}
}

View File

@@ -1,91 +0,0 @@
<?php
namespace BookStack\Activity;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
class WatchLevels
{
/**
* Default level, No specific option set
* Typically not a stored status
*/
const DEFAULT = -1;
/**
* Ignore all notifications.
*/
const IGNORE = 0;
/**
* Watch for new content.
*/
const NEW = 1;
/**
* Watch for updates and new content
*/
const UPDATES = 2;
/**
* Watch for comments, updates and new content.
*/
const COMMENTS = 3;
/**
* Get all the possible values as an option_name => value array.
* @returns array<string, int>
*/
public static function all(): array
{
$options = [];
foreach ((new \ReflectionClass(static::class))->getConstants() as $name => $value) {
$options[strtolower($name)] = $value;
}
return $options;
}
/**
* Get the watch options suited for the given entity.
* @returns array<string, int>
*/
public static function allSuitedFor(Entity $entity): array
{
$options = static::all();
if ($entity instanceof Page) {
unset($options['new']);
} elseif ($entity instanceof Bookshelf) {
return [];
}
return $options;
}
/**
* Convert the given name to a level value.
* Defaults to default value if the level does not exist.
*/
public static function levelNameToValue(string $level): int
{
return static::all()[$level] ?? static::DEFAULT;
}
/**
* Convert the given int level value to a level name.
* Defaults to 'default' level name if not existing.
*/
public static function levelValueToName(int $level): string
{
foreach (static::all() as $name => $value) {
if ($level === $value) {
return $name;
}
}
return 'default';
}
}

View File

@@ -2,7 +2,7 @@
namespace BookStack\Api;
use BookStack\Http\ApiController;
use BookStack\Http\Controllers\Api\ApiController;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Collection;
@@ -16,8 +16,8 @@ use ReflectionMethod;
class ApiDocsGenerator
{
protected array $reflectionClasses = [];
protected array $controllerClasses = [];
protected $reflectionClasses = [];
protected $controllerClasses = [];
/**
* Load the docs form the cache if existing
@@ -27,16 +27,13 @@ class ApiDocsGenerator
{
$appVersion = trim(file_get_contents(base_path('version')));
$cacheKey = 'api-docs::' . $appVersion;
$isProduction = config('app.env') === 'production';
$cacheVal = $isProduction ? Cache::get($cacheKey) : null;
if (!is_null($cacheVal)) {
return $cacheVal;
if (Cache::has($cacheKey) && config('app.env') === 'production') {
$docs = Cache::get($cacheKey);
} else {
$docs = (new ApiDocsGenerator())->generate();
Cache::put($cacheKey, $docs, 60 * 24);
}
$docs = (new ApiDocsGenerator())->generate();
Cache::put($cacheKey, $docs, 60 * 24);
return $docs;
}
@@ -142,10 +139,9 @@ class ApiDocsGenerator
protected function parseDescriptionFromMethodComment(string $comment): string
{
$matches = [];
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
preg_match_all('/^\s*?\*\s((?![@\s]).*?)$/m', $comment, $matches);
$text = implode(' ', $matches[1] ?? []);
return str_replace(' ', "\n", $text);
return implode(' ', $matches[1] ?? []);
}
/**

View File

@@ -10,7 +10,7 @@ class ApiEntityListFormatter
* The list to be formatted.
* @var Entity[]
*/
protected array $list = [];
protected $list = [];
/**
* The fields to show in the formatted data.
@@ -19,9 +19,9 @@ class ApiEntityListFormatter
* will be used for the resultant value. A null return value will omit the property.
* @var array<string|int, string|callable>
*/
protected array $fields = [
'id', 'name', 'slug', 'book_id', 'chapter_id', 'draft',
'template', 'priority', 'created_at', 'updated_at',
protected $fields = [
'id', 'name', 'slug', 'book_id', 'chapter_id',
'draft', 'template', 'created_at', 'updated_at',
];
public function __construct(array $list)

View File

@@ -2,9 +2,8 @@
namespace BookStack\Api;
use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use BookStack\Auth\User;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
@@ -21,8 +20,6 @@ use Illuminate\Support\Carbon;
*/
class ApiToken extends Model implements Loggable
{
use HasFactory;
protected $fillable = ['name', 'expires_at'];
protected $casts = [
'expires_at' => 'date:Y-m-d',
@@ -52,12 +49,4 @@ class ApiToken extends Model implements Loggable
{
return "({$this->id}) {$this->name}; User: {$this->user->logDescriptor()}";
}
/**
* Get the URL for managing this token.
*/
public function getUrl(string $path = ''): string
{
return url("/api-tokens/{$this->user_id}/{$this->id}/" . trim($path, '/'));
}
}

View File

@@ -2,7 +2,7 @@
namespace BookStack\Api;
use BookStack\Access\LoginService;
use BookStack\Auth\Access\LoginService;
use BookStack\Exceptions\ApiAuthException;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable;

View File

@@ -1,67 +0,0 @@
<?php
namespace BookStack\App;
use BookStack\Http\Controller;
use BookStack\Uploads\FaviconHandler;
class MetaController extends Controller
{
/**
* Show the view for /robots.txt.
*/
public function robots()
{
$sitePublic = setting('app-public', false);
$allowRobots = config('app.allow_robots');
if ($allowRobots === null) {
$allowRobots = $sitePublic;
}
return response()
->view('misc.robots', ['allowRobots' => $allowRobots])
->header('Content-Type', 'text/plain');
}
/**
* Show the route for 404 responses.
*/
public function notFound()
{
return response()->view('errors.404', [], 404);
}
/**
* Serve the application favicon.
* Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served
* directly by the webserver in the future.
*/
public function favicon(FaviconHandler $favicons)
{
$exists = $favicons->restoreOriginalIfNotExists();
return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath());
}
/**
* Serve a PWA application manifest.
*/
public function pwaManifest(PwaManifestBuilder $manifestBuilder)
{
return response()->json($manifestBuilder->build());
}
/**
* Show license information for the application.
*/
public function licenses()
{
$this->setPageTitle(trans('settings.licenses'));
return view('help.licenses', [
'license' => file_get_contents(base_path('LICENSE')),
'phpLibData' => file_get_contents(base_path('dev/licensing/php-library-licenses.txt')),
'jsLibData' => file_get_contents(base_path('dev/licensing/js-library-licenses.txt')),
]);
}
}

View File

@@ -1,45 +0,0 @@
<?php
namespace BookStack\App\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use SocialiteProviders\Azure\AzureExtendSocialite;
use SocialiteProviders\Discord\DiscordExtendSocialite;
use SocialiteProviders\GitLab\GitLabExtendSocialite;
use SocialiteProviders\Manager\SocialiteWasCalled;
use SocialiteProviders\Okta\OktaExtendSocialite;
use SocialiteProviders\Twitch\TwitchExtendSocialite;
class EventServiceProvider extends ServiceProvider
{
/**
* The event listener mappings for the application.
*
* @var array<class-string, array<int, class-string>>
*/
protected $listen = [
SocialiteWasCalled::class => [
AzureExtendSocialite::class . '@handle',
OktaExtendSocialite::class . '@handle',
GitLabExtendSocialite::class . '@handle',
TwitchExtendSocialite::class . '@handle',
DiscordExtendSocialite::class . '@handle',
],
];
/**
* Register any events for your application.
*/
public function boot(): void
{
//
}
/**
* Determine if events and listeners should be automatically discovered.
*/
public function shouldDiscoverEvents(): bool
{
return false;
}
}

View File

@@ -1,49 +0,0 @@
<?php
namespace BookStack\App\Providers;
use BookStack\Translation\FileLoader;
use BookStack\Translation\MessageSelector;
use Illuminate\Translation\TranslationServiceProvider as BaseProvider;
use Illuminate\Translation\Translator;
class TranslationServiceProvider extends BaseProvider
{
/**
* Register the service provider.
*/
public function register(): void
{
$this->registerLoader();
// This is a tweak upon Laravel's based translation service registration to allow
// usage of a custom MessageSelector class
$this->app->singleton('translator', function ($app) {
$loader = $app['translation.loader'];
// When registering the translator component, we'll need to set the default
// locale as well as the fallback locale. So, we'll grab the application
// configuration so we can easily get both of these values from there.
$locale = $app['config']['app.locale'];
$trans = new Translator($loader, $locale);
$trans->setFallback($app['config']['app.fallback_locale']);
$trans->setSelector(new MessageSelector());
return $trans;
});
}
/**
* Register the translation line loader.
* Overrides the default register action from Laravel so a custom loader can be used.
*/
protected function registerLoader(): void
{
$this->app->singleton('translation.loader', function ($app) {
return new FileLoader($app['files'], $app['path.lang']);
});
}
}

View File

@@ -1,64 +0,0 @@
<?php
namespace BookStack\App;
class PwaManifestBuilder
{
public function build(): array
{
// Note, while we attempt to use the user's preference here, the request to the manifest
// does not start a session, so we won't have current user context.
// This was attempted but removed since manifest calls could affect user session
// history tracking and back redirection.
// Context: https://github.com/BookStackApp/BookStack/issues/4649
$darkMode = (bool) setting()->getForCurrentUser('dark-mode-enabled');
$appName = setting('app-name');
return [
"name" => $appName,
"short_name" => $appName,
"start_url" => "./",
"scope" => "/",
"display" => "standalone",
"background_color" => $darkMode ? '#111111' : '#F2F2F2',
"description" => $appName,
"theme_color" => ($darkMode ? setting('app-color-dark') : setting('app-color')),
"launch_handler" => [
"client_mode" => "focus-existing"
],
"orientation" => "any",
"icons" => [
[
"src" => setting('app-icon-32') ?: url('/icon-32.png'),
"sizes" => "32x32",
"type" => "image/png"
],
[
"src" => setting('app-icon-64') ?: url('/icon-64.png'),
"sizes" => "64x64",
"type" => "image/png"
],
[
"src" => setting('app-icon-128') ?: url('/icon-128.png'),
"sizes" => "128x128",
"type" => "image/png"
],
[
"src" => setting('app-icon-180') ?: url('/icon-180.png'),
"sizes" => "180x180",
"type" => "image/png"
],
[
"src" => setting('app-icon') ?: url('/icon.png'),
"sizes" => "256x256",
"type" => "image/png"
],
[
"src" => url('favicon.ico'),
"sizes" => "48x48",
"type" => "image/vnd.microsoft.icon"
],
],
];
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\App;
namespace BookStack;
class Application extends \Illuminate\Foundation\Application
{

View File

@@ -1,15 +1,15 @@
<?php
namespace BookStack\Access;
namespace BookStack\Auth\Access;
use BookStack\Access\Notifications\ConfirmEmailNotification;
use BookStack\Auth\User;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Users\Models\User;
use BookStack\Notifications\ConfirmEmail;
class EmailConfirmationService extends UserTokenService
{
protected string $tokenTable = 'email_confirmations';
protected int $expiryTime = 24;
protected $tokenTable = 'email_confirmations';
protected $expiryTime = 24;
/**
* Create new confirmation for a user,
@@ -17,7 +17,7 @@ class EmailConfirmationService extends UserTokenService
*
* @throws ConfirmationEmailException
*/
public function sendConfirmation(User $user): void
public function sendConfirmation(User $user)
{
if ($user->email_confirmed) {
throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login');
@@ -26,7 +26,7 @@ class EmailConfirmationService extends UserTokenService
$this->deleteByUser($user);
$token = $this->createTokenForUser($user);
$user->notify(new ConfirmEmailNotification($token));
$user->notify(new ConfirmEmail($token));
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

136
app/Auth/Access/Ldap.php Normal file
View File

@@ -0,0 +1,136 @@
<?php
namespace BookStack\Auth\Access;
/**
* Class Ldap
* An object-orientated thin abstraction wrapper for common PHP LDAP functions.
* Allows the standard LDAP functions to be mocked for testing.
*/
class Ldap
{
/**
* Connect to an LDAP server.
*
* @return resource
*/
public function connect(string $hostName, int $port)
{
return ldap_connect($hostName, $port);
}
/**
* Set the value of a LDAP option for the given connection.
*
* @param resource $ldapConnection
* @param mixed $value
*/
public function setOption($ldapConnection, int $option, $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 $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 $ldapConnection
* @param string $baseDn
* @param string $filter
* @param array|null $attributes
*
* @return resource
*/
public function search($ldapConnection, $baseDn, $filter, array $attributes = null)
{
return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
}
/**
* Get entries from an ldap search result.
*
* @param resource $ldapConnection
* @param resource $ldapSearchResult
*
* @return array
*/
public function getEntries($ldapConnection, $ldapSearchResult)
{
return ldap_get_entries($ldapConnection, $ldapSearchResult);
}
/**
* Search and get entries immediately.
*
* @param resource $ldapConnection
* @param string $baseDn
* @param string $filter
* @param array|null $attributes
*
* @return resource
*/
public function searchAndGetEntries($ldapConnection, $baseDn, $filter, array $attributes = null)
{
$search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
return $this->getEntries($ldapConnection, $search);
}
/**
* Bind to LDAP directory.
*
* @param resource $ldapConnection
* @param string $bindRdn
* @param string $bindPassword
*
* @return bool
*/
public function bind($ldapConnection, $bindRdn = null, $bindPassword = null)
{
return ldap_bind($ldapConnection, $bindRdn, $bindPassword);
}
/**
* Explode a LDAP dn string into an array of components.
*
* @param string $dn
* @param int $withAttrib
*
* @return array
*/
public function explodeDn(string $dn, int $withAttrib)
{
return ldap_explode_dn($dn, $withAttrib);
}
/**
* Escape a string for use in an LDAP filter.
*
* @param string $value
* @param string $ignore
* @param int $flags
*
* @return string
*/
public function escape(string $value, string $ignore = '', int $flags = 0)
{
return ldap_escape($value, $ignore, $flags);
}
}

View File

@@ -1,11 +1,11 @@
<?php
namespace BookStack\Access;
namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\LdapException;
use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\User;
use ErrorException;
use Illuminate\Support\Facades\Log;
@@ -15,19 +15,26 @@ use Illuminate\Support\Facades\Log;
*/
class LdapService
{
protected Ldap $ldap;
protected GroupSyncService $groupSyncService;
protected UserAvatars $userAvatars;
/**
* @var resource|\LDAP\Connection
* @var resource
*/
protected $ldapConnection;
protected array $config;
protected bool $enabled;
public function __construct(
protected Ldap $ldap,
protected UserAvatars $userAvatars,
protected GroupSyncService $groupSyncService
) {
/**
* LdapService constructor.
*/
public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
{
$this->ldap = $ldap;
$this->userAvatars = $userAvatars;
$this->groupSyncService = $groupSyncService;
$this->config = config('services.ldap');
$this->enabled = config('auth.method') === 'ldap';
}
@@ -52,7 +59,7 @@ class LdapService
// Clean attributes
foreach ($attributes as $index => $attribute) {
if (str_starts_with($attribute, 'BIN;')) {
if (strpos($attribute, 'BIN;') === 0) {
$attributes[$index] = substr($attribute, strlen('BIN;'));
}
}
@@ -75,7 +82,7 @@ class LdapService
* Get the details of a user from LDAP using the given username.
* User found via configurable user filter.
*
* @throws LdapException|JsonDebugException
* @throws LdapException
*/
public function getUserDetails(string $userName): ?array
{
@@ -119,7 +126,7 @@ class LdapService
*/
protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
{
$isBinary = str_starts_with($propertyKey, 'BIN;');
$isBinary = strpos($propertyKey, 'BIN;') === 0;
$propertyKey = strtolower($propertyKey);
$value = $defaultValue;
@@ -163,11 +170,11 @@ class LdapService
* Bind the system user to the LDAP connection using the given credentials
* otherwise anonymous access is attempted.
*
* @param resource|\LDAP\Connection $connection
* @param resource $connection
*
* @throws LdapException
*/
protected function bindSystemUser($connection): void
protected function bindSystemUser($connection)
{
$ldapDn = $this->config['dn'];
$ldapPass = $this->config['pass'];
@@ -190,7 +197,7 @@ class LdapService
*
* @throws LdapException
*
* @return resource|\LDAP\Connection
* @return resource
*/
protected function getConnection()
{
@@ -209,14 +216,8 @@ class LdapService
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
}
// Configure any user-provided CA cert files for LDAP.
// This option works globally and must be set before a connection is created.
if ($this->config['tls_ca_cert']) {
$this->configureTlsCaCerts($this->config['tls_ca_cert']);
}
$ldapHost = $this->parseServerString($this->config['server']);
$ldapConnection = $this->ldap->connect($ldapHost);
$serverDetails = $this->parseServerString($this->config['server']);
$ldapConnection = $this->ldap->connect($serverDetails['host'], $serverDetails['port']);
if ($ldapConnection === false) {
throw new LdapException(trans('errors.ldap_cannot_connect'));
@@ -229,14 +230,7 @@ class LdapService
// Start and verify TLS if it's enabled
if ($this->config['start_tls']) {
try {
$started = $this->ldap->startTls($ldapConnection);
} catch (\Exception $exception) {
$error = $exception->getMessage() . ' :: ' . ldap_error($ldapConnection);
ldap_get_option($ldapConnection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $detail);
Log::info("LDAP STARTTLS failure: {$error} {$detail}");
throw new LdapException('Could not start TLS connection. Further details in the application log.');
}
$started = $this->ldap->startTls($ldapConnection);
if (!$started) {
throw new LdapException('Could not start TLS connection');
}
@@ -248,59 +242,34 @@ class LdapService
}
/**
* Configure TLS CA certs globally for ldap use.
* This will detect if the given path is a directory or file, and set the relevant
* LDAP TLS options appropriately otherwise throw an exception if no file/folder found.
*
* Note: When using a folder, certificates are expected to be correctly named by hash
* which can be done via the c_rehash utility.
*
* @throws LdapException
*/
protected function configureTlsCaCerts(string $caCertPath): void
{
$errMessage = "Provided path [{$caCertPath}] for LDAP TLS CA certs could not be resolved to an existing location";
$path = realpath($caCertPath);
if ($path === false) {
throw new LdapException($errMessage);
}
if (is_dir($path)) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTDIR, $path);
} else if (is_file($path)) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTFILE, $path);
} else {
throw new LdapException($errMessage);
}
}
/**
* Parse an LDAP server string and return the host suitable for a connection.
* Parse a LDAP server string and return the host and port for a connection.
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
*/
protected function parseServerString(string $serverString): string
protected function parseServerString(string $serverString): array
{
if (str_starts_with($serverString, 'ldaps://') || str_starts_with($serverString, 'ldap://')) {
return $serverString;
$serverNameParts = explode(':', $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];
}
return "ldap://{$serverString}";
// Otherwise, extract the port out
$hostName = $serverNameParts[0];
$ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
return ['host' => $hostName, 'port' => $ldapPort];
}
/**
* Build a filter string by injecting common variables.
* Both "${var}" and "{var}" style placeholders are supported.
* Dollar based are old format but supported for compatibility.
*/
protected function buildFilter(string $filterString, array $attrs): string
{
$newAttrs = [];
foreach ($attrs as $key => $attrText) {
$escapedText = $this->ldap->escape($attrText);
$oldVarKey = '${' . $key . '}';
$newVarKey = '{' . $key . '}';
$newAttrs[$oldVarKey] = $escapedText;
$newAttrs[$newVarKey] = $escapedText;
$newKey = '${' . $key . '}';
$newAttrs[$newKey] = $this->ldap->escape($attrText);
}
return strtr($filterString, $newAttrs);
@@ -417,7 +386,7 @@ class LdapService
* @throws LdapException
* @throws JsonDebugException
*/
public function syncGroups(User $user, string $username): void
public function syncGroups(User $user, string $username)
{
$userLdapGroups = $this->getUserGroups($username);
$this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
<?php
namespace BookStack\Auth\Access\Mfa;
use Illuminate\Contracts\Validation\Rule;
class TotpValidationRule implements Rule
{
protected $secret;
protected $totpService;
/**
* Create a new rule instance.
* Takes the TOTP secret that must be system provided, not user provided.
*/
public function __construct(string $secret)
{
$this->secret = $secret;
$this->totpService = app()->make(TotpService::class);
}
/**
* Determine if the validation rule passes.
*/
public function passes($attribute, $value)
{
return $this->totpService->verifyCode($value, $this->secret);
}
/**
* Get the validation error message.
*/
public function message()
{
return trans('validation.totp');
}
}

View File

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

View File

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

View File

@@ -1,19 +1,38 @@
<?php
namespace BookStack\Access\Oidc;
namespace BookStack\Auth\Access\Oidc;
class OidcJwtWithClaims implements ProvidesClaims
class OidcIdToken
{
protected array $header;
protected array $payload;
protected string $signature;
protected string $issuer;
protected array $tokenParts = [];
/**
* @var array
*/
protected $header;
/**
* @var array
*/
protected $payload;
/**
* @var string
*/
protected $signature;
/**
* @var array[]|string[]
*/
protected array $keys;
protected $keys;
/**
* @var string
*/
protected $issuer;
/**
* @var array
*/
protected $tokenParts = [];
public function __construct(string $token, string $issuer, array $keys)
{
@@ -55,15 +74,15 @@ class OidcJwtWithClaims implements ProvidesClaims
}
/**
* Validate common parts of OIDC JWT tokens.
* Validate all possible parts of the id token.
*
* @throws OidcInvalidTokenException
*/
public function validateCommonTokenDetails(string $clientId): bool
public function validate(string $clientId): bool
{
$this->validateTokenStructure();
$this->validateTokenSignature();
$this->validateCommonClaims($clientId);
$this->validateTokenClaims($clientId);
return true;
}
@@ -71,8 +90,10 @@ class OidcJwtWithClaims implements ProvidesClaims
/**
* Fetch a specific claim from this token.
* Returns null if it is null or does not exist.
*
* @return mixed|null
*/
public function getClaim(string $claim): mixed
public function getClaim(string $claim)
{
return $this->payload[$claim] ?? null;
}
@@ -85,14 +106,6 @@ class OidcJwtWithClaims implements ProvidesClaims
return $this->payload;
}
/**
* Replace the existing claim data of this token with that provided.
*/
public function replaceClaims(array $claims): void
{
$this->payload = $claims;
}
/**
* Validate the structure of the given token and ensure we have the required pieces.
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
@@ -145,13 +158,12 @@ class OidcJwtWithClaims implements ProvidesClaims
}
/**
* Validate common claims for OIDC JWT tokens.
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation
* and https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
* Validate the claims of the token.
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
*
* @throws OidcInvalidTokenException
*/
protected function validateCommonClaims(string $clientId): void
protected function validateTokenClaims(string $clientId): void
{
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
// MUST exactly match the value of the iss (issuer) Claim.
@@ -161,14 +173,66 @@ class OidcJwtWithClaims implements ProvidesClaims
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
// if the ID Token does not list the Client as a valid audience.
// if the ID Token does not list the Client as a valid audience, or if it contains additional
// audiences not trusted by the Client.
if (empty($this->payload['aud'])) {
throw new OidcInvalidTokenException('Missing token audience value');
}
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
if (!in_array($clientId, $aud, true)) {
if (count($aud) !== 1) {
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
}
if ($aud[0] !== $clientId) {
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
}
// 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
// NOTE: Addressed by enforcing a count of 1 above.
// 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id
// is the Claim Value.
if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) {
throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id');
}
// 5. The current time MUST be before the time represented by the exp Claim
// (possibly allowing for some small leeway to account for clock skew).
if (empty($this->payload['exp'])) {
throw new OidcInvalidTokenException('Missing token expiration time value');
}
$skewSeconds = 120;
$now = time();
if ($now >= (intval($this->payload['exp']) + $skewSeconds)) {
throw new OidcInvalidTokenException('Token has expired');
}
// 6. The iat Claim can be used to reject tokens that were issued too far away from the current time,
// limiting the amount of time that nonces need to be stored to prevent attacks.
// The acceptable range is Client specific.
if (empty($this->payload['iat'])) {
throw new OidcInvalidTokenException('Missing token issued at time value');
}
$dayAgo = time() - 86400;
$iat = intval($this->payload['iat']);
if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) {
throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid');
}
// 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate.
// The meaning and processing of acr Claim Values is out of scope for this document.
// NOTE: Not used for our case here. acr is not requested.
// 8. When a max_age request is made, the Client SHOULD check the auth_time Claim value and request
// re-authentication if it determines too much time has elapsed since the last End-User authentication.
// NOTE: Not used for our case here. A max_age request is not made.
// Custom: Ensure the "sub" (Subject) Claim exists and has a value.
if (empty($this->payload['sub'])) {
throw new OidcInvalidTokenException('Missing token subject value');
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access\Oidc;
namespace BookStack\Auth\Access\Oidc;
use League\OAuth2\Client\Grant\AbstractGrant;
use League\OAuth2\Client\Provider\AbstractProvider;
@@ -20,8 +20,15 @@ class OidcOAuthProvider extends AbstractProvider
{
use BearerAuthorizationTrait;
protected string $authorizationEndpoint;
protected string $tokenEndpoint;
/**
* @var string
*/
protected $authorizationEndpoint;
/**
* @var string
*/
protected $tokenEndpoint;
/**
* Scopes to use for the OIDC authorization call.
@@ -53,7 +60,7 @@ class OidcOAuthProvider extends AbstractProvider
}
/**
* Add another scope to this provider upon the default.
* Add an additional scope to this provider upon the default.
*/
public function addScope(string $scope): void
{
@@ -83,9 +90,15 @@ class OidcOAuthProvider extends AbstractProvider
/**
* Checks a provider response for errors.
*
* @param ResponseInterface $response
* @param array|string $data Parsed response data
*
* @throws IdentityProviderException
*
* @return void
*/
protected function checkResponse(ResponseInterface $response, $data): void
protected function checkResponse(ResponseInterface $response, $data)
{
if ($response->getStatusCode() >= 400 || isset($data['error'])) {
throw new IdentityProviderException(
@@ -99,8 +112,13 @@ class OidcOAuthProvider extends AbstractProvider
/**
* Generates a resource owner object from a successful resource owner
* details request.
*
* @param array $response
* @param AccessToken $token
*
* @return ResourceOwnerInterface
*/
protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface
protected function createResourceOwner(array $response, AccessToken $token)
{
return new GenericResourceOwner($response, '');
}
@@ -110,18 +128,14 @@ class OidcOAuthProvider extends AbstractProvider
*
* The grant that was used to fetch the response can be used to provide
* additional context.
*
* @param array $response
* @param AbstractGrant $grant
*
* @return OidcAccessToken
*/
protected function createAccessToken(array $response, AbstractGrant $grant): OidcAccessToken
protected function createAccessToken(array $response, AbstractGrant $grant)
{
return new OidcAccessToken($response);
}
/**
* Get the method used for PKCE code verifier hashing, which is passed
* in the "code_challenge_method" parameter in the authorization request.
*/
protected function getPkceMethod(): string
{
return static::PKCE_METHOD_S256;
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace BookStack\Access\Oidc;
namespace BookStack\Auth\Access\Oidc;
use GuzzleHttp\Psr7\Request;
use Illuminate\Contracts\Cache\Repository;
@@ -18,10 +18,9 @@ class OidcProviderSettings
public string $issuer;
public string $clientId;
public string $clientSecret;
public ?string $redirectUri;
public ?string $authorizationEndpoint;
public ?string $tokenEndpoint;
public ?string $endSessionEndpoint;
public ?string $userinfoEndpoint;
/**
* @var string[]|array[]
@@ -37,7 +36,7 @@ class OidcProviderSettings
/**
* Apply an array of settings to populate setting properties within this class.
*/
protected function applySettingsFromArray(array $settingsArray): void
protected function applySettingsFromArray(array $settingsArray)
{
foreach ($settingsArray as $key => $value) {
if (property_exists($this, $key)) {
@@ -51,16 +50,16 @@ class OidcProviderSettings
*
* @throws InvalidArgumentException
*/
protected function validateInitial(): void
protected function validateInitial()
{
$required = ['clientId', 'clientSecret', 'issuer'];
$required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
}
}
if (!str_starts_with($this->issuer, 'https://')) {
if (strpos($this->issuer, 'https://') !== 0) {
throw new InvalidArgumentException('Issuer value must start with https://');
}
}
@@ -73,20 +72,12 @@ class OidcProviderSettings
public function validate(): void
{
$this->validateInitial();
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
}
}
$endpointProperties = ['tokenEndpoint', 'authorizationEndpoint', 'userinfoEndpoint'];
foreach ($endpointProperties as $prop) {
if (is_string($this->$prop) && !str_starts_with($this->$prop, 'https://')) {
throw new InvalidArgumentException("Endpoint value for \"{$prop}\" must start with https://");
}
}
}
/**
@@ -94,7 +85,7 @@ class OidcProviderSettings
*
* @throws OidcIssuerDiscoveryException
*/
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes): void
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
{
try {
$cacheKey = 'oidc-discovery::' . $this->issuer;
@@ -136,19 +127,11 @@ class OidcProviderSettings
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
}
if (!empty($result['userinfo_endpoint'])) {
$discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint'];
}
if (!empty($result['jwks_uri'])) {
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
$discoveredSettings['keys'] = $this->filterKeys($keys);
}
if (!empty($result['end_session_endpoint'])) {
$discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
}
return $discoveredSettings;
}
@@ -187,9 +170,9 @@ class OidcProviderSettings
/**
* Get the settings needed by an OAuth provider, as a key=>value array.
*/
public function arrayForOAuthProvider(): array
public function arrayForProvider(): array
{
$settingKeys = ['clientId', 'clientSecret', 'authorizationEndpoint', 'tokenEndpoint', 'userinfoEndpoint'];
$settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
$settings = [];
foreach ($settingKeys as $setting) {
$settings[$setting] = $this->$setting;

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