mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 16:49:47 +03:00
Compare commits
24 Commits
vectors
...
user_permi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3083979855 | ||
|
|
55642a33ee | ||
|
|
93ba572369 | ||
|
|
a825f27930 | ||
|
|
932e1d7c61 | ||
|
|
2f1491c5a4 | ||
|
|
026e9030b9 | ||
|
|
451e4ac452 | ||
|
|
7330139555 | ||
|
|
39acbeac68 | ||
|
|
2d9d2bba80 | ||
|
|
adabf06dbe | ||
|
|
5ffc10e688 | ||
|
|
6a6f5e4d19 | ||
|
|
491beee93e | ||
|
|
f844ae0902 | ||
|
|
d54ea1b3ed | ||
|
|
e8a8fedfd6 | ||
|
|
60bf838a4a | ||
|
|
0411185fbb | ||
|
|
93cbd3b8aa | ||
|
|
7a269e7689 | ||
|
|
f8c4725166 | ||
|
|
1c53ffc4d1 |
11
.env.example
11
.env.example
@@ -26,13 +26,6 @@ DB_DATABASE=database_database
|
||||
DB_USERNAME=database_username
|
||||
DB_PASSWORD=database_user_password
|
||||
|
||||
# Storage system to use
|
||||
# By default files are stored on the local filesystem, with images being placed in
|
||||
# public web space so they can be efficiently served directly by the web-server.
|
||||
# For other options with different security levels & considerations, refer to:
|
||||
# https://www.bookstackapp.com/docs/admin/upload-config/
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# Mail system to use
|
||||
# Can be 'smtp' or 'sendmail'
|
||||
MAIL_DRIVER=smtp
|
||||
@@ -44,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
|
||||
|
||||
@@ -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
|
||||
@@ -36,14 +32,10 @@ APP_LANG=en
|
||||
# APP_LANG will be used if such a header is not provided.
|
||||
APP_AUTO_LANG_PUBLIC=true
|
||||
|
||||
# Application timezones
|
||||
# The first option is used to determine what timezone is used for date storage.
|
||||
# Leaving that as "UTC" is advised.
|
||||
# The second option is used to set the timezone which will be used for date
|
||||
# formatting and display. This defaults to the "APP_TIMEZONE" value.
|
||||
# Application timezone
|
||||
# Used where dates are displayed such as on exported content.
|
||||
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
|
||||
APP_TIMEZONE=UTC
|
||||
APP_DISPLAY_TIMEZONE=UTC
|
||||
|
||||
# Application theme
|
||||
# Used to specific a themes/<APP_THEME> folder where BookStack UI
|
||||
@@ -60,7 +52,6 @@ APP_PROXIES=null
|
||||
|
||||
# Database details
|
||||
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
|
||||
# An ipv6 address can be used via the square bracket format ([::1]).
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=database_database
|
||||
@@ -74,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'
|
||||
@@ -220,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
|
||||
@@ -273,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
|
||||
@@ -331,26 +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
|
||||
|
||||
# Export PDF Command Timeout
|
||||
# The number of seconds that the export PDF command will run before a timeout occurs.
|
||||
# Only applies for the EXPORT_PDF_COMMAND option, not for DomPDF or wkhtmltopdf.
|
||||
EXPORT_PDF_COMMAND_TIMEOUT=15
|
||||
|
||||
# Set path to wkhtmltopdf binary for PDF generation.
|
||||
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
|
||||
# When false, BookStack will attempt to find a wkhtmltopdf in the application
|
||||
# root folder then fall back to the default dompdf renderer if no binary exists.
|
||||
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
||||
WKHTMLTOPDF=false
|
||||
|
||||
# Allow <script> tags in page content
|
||||
# Note, if set to 'true' the page editor may still escape scripts.
|
||||
ALLOW_CONTENT_SCRIPTS=false
|
||||
@@ -380,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
|
||||
@@ -410,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
|
||||
33
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
33
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
8
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -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:
|
||||
|
||||
13
.github/ISSUE_TEMPLATE/support_request.yml
vendored
13
.github/ISSUE_TEMPLATE/support_request.yml
vendored
@@ -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
|
||||
@@ -42,7 +42,14 @@ body:
|
||||
label: Log Content
|
||||
description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below.
|
||||
placeholder: Be sure to remove any confidential details in your logs
|
||||
render: text
|
||||
validations:
|
||||
required: false
|
||||
- type: 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
|
||||
@@ -50,6 +57,6 @@ body:
|
||||
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
|
||||
|
||||
9
.github/ISSUE_TEMPLATE/z_blank_request.yml
vendored
9
.github/ISSUE_TEMPLATE/z_blank_request.yml
vendored
@@ -1,9 +0,0 @@
|
||||
name: Blank Request (Maintainers Only)
|
||||
description: For maintainers only - Start a blank request
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "**This blank request option is only for existing official maintainers of the project!** Please instead use a different request option. If you use this your issue will be closed off."
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
15
.github/SECURITY.md
vendored
15
.github/SECURITY.md
vendored
@@ -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!
|
||||
235
.github/translators.txt
vendored
235
.github/translators.txt
vendored
@@ -57,7 +57,6 @@ Name :: Languages
|
||||
@Jokuna :: Korean
|
||||
@smartshogu :: German; German Informal
|
||||
@samadha56 :: Persian
|
||||
@mrmuminov :: Uzbek
|
||||
cipi1965 :: Italian
|
||||
Mykola Ronik (Mantikor) :: Ukrainian
|
||||
furkanoyk :: Turkish
|
||||
@@ -141,7 +140,7 @@ Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
|
||||
MatthieuParis :: French
|
||||
Douradinho :: Portuguese, Brazilian; Portuguese
|
||||
Gaku Yaguchi (tama11) :: Japanese
|
||||
Zero Huang (johnroyer) :: Chinese Traditional
|
||||
johnroyer :: Chinese Traditional
|
||||
jackaaa :: Chinese Traditional
|
||||
Irfan Hukama Arsyad (IrfanArsyad) :: Indonesian
|
||||
Jeff Huang (s8321414) :: Chinese Traditional
|
||||
@@ -177,7 +176,7 @@ Alexander Predl (Harveyhase68) :: German
|
||||
Rem (Rem9000) :: Dutch
|
||||
Michał Stelmach (stelmach-web) :: Polish
|
||||
arniom :: French
|
||||
REMOVED_USER :: French; German; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
|
||||
REMOVED_USER :: ; Dutch; Turkish
|
||||
林祖年 (contagion) :: Chinese Traditional
|
||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||
@@ -222,7 +221,7 @@ SmokingCrop :: Dutch
|
||||
Maciej Lebiest (Szwendacz) :: Polish
|
||||
DiscordDigital :: German; German Informal
|
||||
Gábor Marton (dodver) :: Hungarian
|
||||
Jakob Åsell (Jasell) :: Swedish
|
||||
Jasell :: Swedish
|
||||
Ghost_chu (ghostchu) :: Chinese Simplified
|
||||
Ravid Shachar (ravidshachar) :: Hebrew
|
||||
Helga Guchshenskaya (guchshenskaya) :: Russian
|
||||
@@ -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,225 +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; German Informal
|
||||
Larry (lahoje) :: Swedish
|
||||
Marcia dos Santos (marciab80) :: Portuguese
|
||||
Ricard López Torres (richilpez.torres) :: Catalan
|
||||
sarahalves7 :: Portuguese, Brazilian
|
||||
petr.husak :: Czech
|
||||
javadataherian :: Persian
|
||||
Ludo-code :: French
|
||||
hollsten :: Swedish
|
||||
Ngoc Lan Phung (lanpncz) :: Vietnamese
|
||||
Worive :: Catalan; French
|
||||
Илья Скаба (skabailya) :: Russian
|
||||
Irjan Olsen (Irch) :: Norwegian Bokmal
|
||||
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
|
||||
Red (RedVortex) :: Hebrew
|
||||
xgrug :: Chinese Simplified
|
||||
HrCalmar :: Danish
|
||||
Avishay Rapp (AvishayRapp) :: Hebrew
|
||||
matthias4217 :: French
|
||||
Berke BOYLU2 (berkeboylu2) :: Turkish
|
||||
etwas7B :: German
|
||||
Mohammed srhiri (m.sghiri20) :: Arabic
|
||||
YongMin Kim (kym0118) :: Korean
|
||||
Rivo Zängov (Eraser) :: Estonian
|
||||
Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian
|
||||
ИEØ_ΙΙØZ (NEO_IIOZ) :: Chinese Traditional
|
||||
madnjpn (madnjpn.) :: Georgian
|
||||
Ásgeir Shiny Ásgeirsson (AsgeirShiny) :: Icelandic
|
||||
Mohammad Aftab Uddin (chirohorit) :: Bengali
|
||||
Yannis Karlaftis (meliseus) :: Greek
|
||||
felixxx :: German Informal
|
||||
randi (randi65535) :: Korean
|
||||
test65428 :: Greek
|
||||
zeronell :: Chinese Simplified
|
||||
julien Vinber (julienVinber) :: French
|
||||
Hyunwoo Park (oksure) :: Korean
|
||||
aram.rafeq.7 (aramrafeq2) :: Kurdish
|
||||
Raphael Moreno (RaphaelMoreno) :: Portuguese, Brazilian
|
||||
yn (user99) :: Arabic
|
||||
Pavel Zlatarov (pzlatarov) :: Bulgarian
|
||||
ingelres :: French
|
||||
mabdullah :: Arabic
|
||||
Skrabák Csaba (kekcsi) :: Hungarian
|
||||
Evert Meulie (Evert) :: Norwegian Bokmal
|
||||
Jasper Backer (jasperb) :: Dutch
|
||||
Alexandar Cavdarovski (ace.200112) :: Swedish
|
||||
구닥다리TV (yjj8353) :: Korean
|
||||
Onur Oskay (o.oskay) :: Turkish
|
||||
Sébastien Merveille (SebastienMerv) :: French
|
||||
Maxim Kouznetsov (masya.work) :: Hebrew
|
||||
neodvisnost :: Slovenian
|
||||
Soubi Agatsuma (bisouya) :: Hebrew
|
||||
Ilya Shaulov (ishaulov) :: Russian
|
||||
Konstantin Bobkov (b.konstantv) :: Russian
|
||||
Ruben Sutter (rubensutter) :: German
|
||||
jellium :: French
|
||||
Qxlkdr :: Swedish
|
||||
Hari (muhhari) :: Indonesian
|
||||
仙君御 (xjy) :: Chinese Simplified
|
||||
TapioM :: Finnish
|
||||
lingb58 :: Chinese Traditional
|
||||
Angel Pandey (angel-pandey) :: Nepali
|
||||
Supriya Shrestha (supriyashrestha) :: Nepali
|
||||
gprabhat :: Nepali
|
||||
CellCat :: Chinese Simplified
|
||||
Al Desrahim (aldesrahim) :: Indonesian
|
||||
ahmad abbaspour (deshneh.dar.diss) :: Persian
|
||||
Erjon K. (ekr) :: Albanian
|
||||
LiZerui (iamzrli) :: Chinese Traditional
|
||||
Ticker (ticker.com) :: Hebrew
|
||||
CrazyComputer :: Chinese Simplified
|
||||
Firr (FirrV) :: Russian
|
||||
João Faro (FaroJoaoFaro) :: Portuguese
|
||||
Danilo dos Santos Barbosa (bozochegou) :: Portuguese, Brazilian
|
||||
Chris (furesoft) :: German
|
||||
Silvia Isern (eiendragon) :: Catalan
|
||||
Dennis Kron Pedersen (ahjdp) :: Danish
|
||||
iamwhoiamwhoami :: Swedish
|
||||
Grogui :: French
|
||||
MrCharlesIII :: Arabic
|
||||
David Olsen (dawin) :: Danish
|
||||
ltnzr :: French
|
||||
Frank Holler (holler.frank) :: German; German Informal
|
||||
Korab Arifi (korabidev) :: Albanian
|
||||
Petr Husák (petrhusak) :: Czech
|
||||
Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian
|
||||
Amr (amr3k) :: Arabic
|
||||
Tahsin Ahmed (tahsinahmed2012) :: Bengali
|
||||
bojan_che :: Serbian (Cyrillic)
|
||||
setiawan setiawan (culture.setiawan) :: Indonesian
|
||||
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
|
||||
Gabriel Silver (GabrielBSilver) :: Hebrew
|
||||
Tomas Darius Davainis (Tomasdd) :: Lithuanian
|
||||
|
||||
18
.github/workflows/analyse-php.yml
vendored
18
.github/workflows/analyse-php.yml
vendored
@@ -1,24 +1,18 @@
|
||||
name: analyse-php
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.php'
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- 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
|
||||
|
||||
24
.github/workflows/lint-js.yml
vendored
24
.github/workflows/lint-js.yml
vendored
@@ -1,24 +0,0 @@
|
||||
name: lint-js
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.json'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install NPM deps
|
||||
run: npm ci
|
||||
|
||||
- name: Run formatting check
|
||||
run: npm run lint
|
||||
14
.github/workflows/lint-php.yml
vendored
14
.github/workflows/lint-php.yml
vendored
@@ -1,24 +1,18 @@
|
||||
name: lint-php
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.php'
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.3
|
||||
php-version: 8.1
|
||||
tools: phpcs
|
||||
|
||||
- name: Run formatting check
|
||||
|
||||
29
.github/workflows/test-js.yml
vendored
29
.github/workflows/test-js.yml
vendored
@@ -1,29 +0,0 @@
|
||||
name: test-js
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.ts'
|
||||
- '**.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.ts'
|
||||
- '**.json'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install NPM deps
|
||||
run: npm ci
|
||||
|
||||
- name: Run TypeScript type checking
|
||||
run: npm run ts:lint
|
||||
|
||||
- name: Run JavaScript tests
|
||||
run: npm run test
|
||||
18
.github/workflows/test-migrations.yml
vendored
18
.github/workflows/test-migrations.yml
vendored
@@ -1,24 +1,16 @@
|
||||
name: test-migrations
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
- 'composer.*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.php'
|
||||
- 'composer.*'
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.2', '8.3', '8.4', '8.5']
|
||||
php: ['7.4', '8.0', '8.1', '8.2']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
@@ -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 }}
|
||||
|
||||
20
.github/workflows/test-php.yml
vendored
20
.github/workflows/test-php.yml
vendored
@@ -1,30 +1,22 @@
|
||||
name: test-php
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
- 'composer.*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.php'
|
||||
- 'composer.*'
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.2', '8.3', '8.4', '8.5']
|
||||
php: ['7.4', '8.0', '8.1', '8.2']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap, 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 }}
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,8 +1,5 @@
|
||||
/vendor
|
||||
/node_modules
|
||||
/.vscode
|
||||
/composer
|
||||
/coverage
|
||||
Homestead.yaml
|
||||
.env
|
||||
.idea
|
||||
@@ -14,7 +11,6 @@ yarn-error.log
|
||||
/public/js
|
||||
/public/bower
|
||||
/public/build/
|
||||
/public/favicon.ico
|
||||
/storage/images
|
||||
_ide_helper.php
|
||||
/storage/debugbar
|
||||
@@ -24,12 +20,8 @@ yarn.lock
|
||||
nbproject
|
||||
.buildpath
|
||||
.project
|
||||
.nvmrc
|
||||
.settings/
|
||||
webpack-stats.json
|
||||
.phpunit.result.cache
|
||||
.DS_Store
|
||||
phpstan.neon
|
||||
esbuild-meta.json
|
||||
.phpactor.json
|
||||
/*.zip
|
||||
phpstan.neon
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-2026, 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
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\UserProvider;
|
||||
|
||||
class ExternalBaseUserProvider implements UserProvider
|
||||
{
|
||||
/**
|
||||
* Retrieve a user by their unique identifier.
|
||||
*/
|
||||
public function retrieveById(mixed $identifier): ?Authenticatable
|
||||
{
|
||||
return User::query()->find($identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user by their unique identifier and "remember me" token.
|
||||
*
|
||||
* @param string $token
|
||||
*/
|
||||
public function retrieveByToken(mixed $identifier, $token): null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the "remember me" token for the given user in storage.
|
||||
*
|
||||
* @param Authenticatable $user
|
||||
* @param string $token
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updateRememberToken(Authenticatable $user, $token)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user by the given credentials.
|
||||
*/
|
||||
public function retrieveByCredentials(array $credentials): ?Authenticatable
|
||||
{
|
||||
return User::query()
|
||||
->where('external_auth_id', $credentials['external_auth_id'])
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user against the given credentials.
|
||||
*/
|
||||
public function validateCredentials(Authenticatable $user, array $credentials): bool
|
||||
{
|
||||
// Should be done in the guard.
|
||||
return false;
|
||||
}
|
||||
|
||||
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
|
||||
{
|
||||
// No action to perform, any passwords are external in the auth system
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Guards;
|
||||
|
||||
/**
|
||||
* External Auth Session Guard.
|
||||
*
|
||||
* The login process for external auth (SAML2/OIDC) is async in nature, meaning it does not fit very well
|
||||
* into the default laravel 'Guard' auth flow. Instead, most of the logic is done via the relevant
|
||||
* controller and services. This class provides a safer, thin version of SessionGuard.
|
||||
*/
|
||||
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
|
||||
{
|
||||
/**
|
||||
* Validate a user's credentials.
|
||||
*/
|
||||
public function validate(array $credentials = []): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate a user using the given credentials.
|
||||
*
|
||||
* @param bool $remember
|
||||
*/
|
||||
public function attempt(array $credentials = [], $remember = false): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Mfa;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class TotpValidationRule implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Create a new rule instance.
|
||||
* Takes the TOTP secret that must be system provided, not user provided.
|
||||
*/
|
||||
public function __construct(
|
||||
protected string $secret,
|
||||
protected TotpService $totpService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$passes = $this->totpService->verifyCode($value, $this->secret);
|
||||
if (!$passes) {
|
||||
$fail(trans('validation.totp'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Notifications;
|
||||
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class ConfirmEmailNotification extends MailNotification
|
||||
{
|
||||
public function __construct(
|
||||
public string $token
|
||||
) {
|
||||
}
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$appName = ['appName' => setting('app-name')];
|
||||
|
||||
return $this->newMailMessage()
|
||||
->subject(trans('auth.email_confirm_subject', $appName))
|
||||
->greeting(trans('auth.email_confirm_greeting', $appName))
|
||||
->line(trans('auth.email_confirm_text'))
|
||||
->action(trans('auth.email_confirm_action'), url('/register/confirm/' . $this->token));
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Notifications;
|
||||
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class ResetPasswordNotification extends MailNotification
|
||||
{
|
||||
public function __construct(
|
||||
public string $token
|
||||
) {
|
||||
}
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
return $this->newMailMessage()
|
||||
->subject(trans('auth.email_reset_subject', ['appName' => setting('app-name')]))
|
||||
->line(trans('auth.email_reset_text'))
|
||||
->action(trans('auth.reset_password'), url('password/reset/' . $this->token))
|
||||
->line(trans('auth.email_reset_not_requested'));
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Notifications;
|
||||
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class UserInviteNotification extends MailNotification
|
||||
{
|
||||
public function __construct(
|
||||
public string $token
|
||||
) {
|
||||
}
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$appName = ['appName' => setting('app-name')];
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
->subject($locale->trans('auth.user_invite_email_subject', $appName))
|
||||
->greeting($locale->trans('auth.user_invite_email_greeting', $appName))
|
||||
->line($locale->trans('auth.user_invite_email_text'))
|
||||
->action($locale->trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
class OidcIdToken extends OidcJwtWithClaims implements ProvidesClaims
|
||||
{
|
||||
/**
|
||||
* Validate all possible parts of the id token.
|
||||
*
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
public function validate(string $clientId): bool
|
||||
{
|
||||
parent::validateCommonTokenDetails($clientId);
|
||||
$this->validateTokenClaims($clientId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the claims of the token.
|
||||
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
|
||||
*
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
protected function validateTokenClaims(string $clientId): void
|
||||
{
|
||||
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
|
||||
// MUST exactly match the value of the iss (issuer) Claim.
|
||||
// Already done in parent.
|
||||
|
||||
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
|
||||
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
|
||||
// if the ID Token does not list the Client as a valid audience, or if it contains additional
|
||||
// audiences not trusted by the Client.
|
||||
// Partially done in parent.
|
||||
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
|
||||
if (count($aud) !== 1) {
|
||||
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
|
||||
}
|
||||
|
||||
// 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
|
||||
// NOTE: Addressed by enforcing a count of 1 above.
|
||||
|
||||
// 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id
|
||||
// is the Claim Value.
|
||||
if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) {
|
||||
throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id');
|
||||
}
|
||||
|
||||
// 5. The current time MUST be before the time represented by the exp Claim
|
||||
// (possibly allowing for some small leeway to account for clock skew).
|
||||
if (empty($this->payload['exp'])) {
|
||||
throw new OidcInvalidTokenException('Missing token expiration time value');
|
||||
}
|
||||
|
||||
$skewSeconds = 120;
|
||||
$now = time();
|
||||
if ($now >= (intval($this->payload['exp']) + $skewSeconds)) {
|
||||
throw new OidcInvalidTokenException('Token has expired');
|
||||
}
|
||||
|
||||
// 6. The iat Claim can be used to reject tokens that were issued too far away from the current time,
|
||||
// limiting the amount of time that nonces need to be stored to prevent attacks.
|
||||
// The acceptable range is Client specific.
|
||||
if (empty($this->payload['iat'])) {
|
||||
throw new OidcInvalidTokenException('Missing token issued at time value');
|
||||
}
|
||||
|
||||
$dayAgo = time() - 86400;
|
||||
$iat = intval($this->payload['iat']);
|
||||
if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) {
|
||||
throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid');
|
||||
}
|
||||
|
||||
// 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate.
|
||||
// The meaning and processing of acr Claim Values is out of scope for this document.
|
||||
// NOTE: Not used for our case here. acr is not requested.
|
||||
|
||||
// 8. When a max_age request is made, the Client SHOULD check the auth_time Claim value and request
|
||||
// re-authentication if it determines too much time has elapsed since the last End-User authentication.
|
||||
// NOTE: Not used for our case here. A max_age request is not made.
|
||||
|
||||
// Custom: Ensure the "sub" (Subject) Claim exists and has a value.
|
||||
if (empty($this->payload['sub'])) {
|
||||
throw new OidcInvalidTokenException('Missing token subject value');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class OidcUserDetails
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $externalId = null,
|
||||
public ?string $email = null,
|
||||
public ?string $name = null,
|
||||
public ?array $groups = null,
|
||||
public ?string $picture = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user details are fully populated for our usage.
|
||||
*/
|
||||
public function isFullyPopulated(bool $groupSyncActive): bool
|
||||
{
|
||||
$hasEmpty = empty($this->externalId)
|
||||
|| empty($this->email)
|
||||
|| empty($this->name)
|
||||
|| ($groupSyncActive && $this->groups === null);
|
||||
|
||||
return !$hasEmpty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate user details from the given claim data.
|
||||
*/
|
||||
public function populate(
|
||||
ProvidesClaims $claims,
|
||||
string $idClaim,
|
||||
string $displayNameClaims,
|
||||
string $groupsClaim,
|
||||
): void {
|
||||
$this->externalId = $claims->getClaim($idClaim) ?? $this->externalId;
|
||||
$this->email = $claims->getClaim('email') ?? $this->email;
|
||||
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
|
||||
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
|
||||
$this->picture = static::getPicture($claims) ?: $this->picture;
|
||||
}
|
||||
|
||||
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $claims): string
|
||||
{
|
||||
$displayNameClaimParts = explode('|', $displayNameClaims);
|
||||
|
||||
$displayName = [];
|
||||
foreach ($displayNameClaimParts as $claim) {
|
||||
$component = $claims->getClaim(trim($claim)) ?? '';
|
||||
if ($component !== '') {
|
||||
$displayName[] = $component;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(' ', $displayName);
|
||||
}
|
||||
|
||||
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $claims): ?array
|
||||
{
|
||||
if (empty($groupsClaim)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$groupsList = Arr::get($claims->getAllClaims(), $groupsClaim);
|
||||
if (!is_array($groupsList)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_values(array_filter($groupsList, function ($val) {
|
||||
return is_string($val);
|
||||
}));
|
||||
}
|
||||
|
||||
protected static function getPicture(ProvidesClaims $claims): ?string
|
||||
{
|
||||
$picture = $claims->getClaim('picture');
|
||||
if (is_string($picture) && str_starts_with($picture, 'http')) {
|
||||
return $picture;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class OidcUserinfoResponse implements ProvidesClaims
|
||||
{
|
||||
protected array $claims = [];
|
||||
protected ?OidcJwtWithClaims $jwt = null;
|
||||
|
||||
public function __construct(ResponseInterface $response, string $issuer, array $keys)
|
||||
{
|
||||
$contentTypeHeaderValue = $response->getHeader('Content-Type')[0] ?? '';
|
||||
$contentType = strtolower(trim(explode(';', $contentTypeHeaderValue, 2)[0]));
|
||||
|
||||
if ($contentType === 'application/json') {
|
||||
$this->claims = json_decode($response->getBody()->getContents(), true);
|
||||
}
|
||||
|
||||
if ($contentType === 'application/jwt') {
|
||||
$this->jwt = new OidcJwtWithClaims($response->getBody()->getContents(), $issuer, $keys);
|
||||
$this->claims = $this->jwt->getAllClaims();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
public function validate(string $idTokenSub, string $clientId): bool
|
||||
{
|
||||
if (!is_null($this->jwt)) {
|
||||
$this->jwt->validateCommonTokenDetails($clientId);
|
||||
}
|
||||
|
||||
$sub = $this->getClaim('sub');
|
||||
|
||||
// Spec: v1.0 5.3.2: The sub (subject) Claim MUST always be returned in the UserInfo Response.
|
||||
if (!is_string($sub) || empty($sub)) {
|
||||
throw new OidcInvalidTokenException("No valid subject value found in userinfo data");
|
||||
}
|
||||
|
||||
// Spec: v1.0 5.3.2: The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token;
|
||||
// if they do not match, the UserInfo Response values MUST NOT be used.
|
||||
if ($idTokenSub !== $sub) {
|
||||
throw new OidcInvalidTokenException("Subject value provided in the userinfo endpoint does not match the provided ID token value");
|
||||
}
|
||||
|
||||
// Spec v1.0 5.3.4 Defines the following:
|
||||
// Verify that the OP that responded was the intended OP through a TLS server certificate check, per RFC 6125 [RFC6125].
|
||||
// This is effectively done as part of the HTTP request we're making through CURLOPT_SSL_VERIFYHOST on the request.
|
||||
// If the Client has provided a userinfo_encrypted_response_alg parameter during Registration, decrypt the UserInfo Response using the keys specified during Registration.
|
||||
// We don't currently support JWT encryption for OIDC
|
||||
// If the response was signed, the Client SHOULD validate the signature according to JWS [JWS].
|
||||
// This is done as part of the validateCommonClaims above.
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getClaim(string $claim): mixed
|
||||
{
|
||||
return $this->claims[$claim] ?? null;
|
||||
}
|
||||
|
||||
public function getAllClaims(): array
|
||||
{
|
||||
return $this->claims;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Oidc;
|
||||
|
||||
interface ProvidesClaims
|
||||
{
|
||||
/**
|
||||
* Fetch a specific claim.
|
||||
* Returns null if it is null or does not exist.
|
||||
*/
|
||||
public function getClaim(string $claim): mixed;
|
||||
|
||||
/**
|
||||
* Get all contained claims.
|
||||
*/
|
||||
public function getAllClaims(): array;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property string $driver
|
||||
* @property User $user
|
||||
*/
|
||||
class SocialAccount extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['user_id', 'driver', 'driver_id'];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "{$this->driver}; {$this->user->logDescriptor()}";
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Exceptions\SocialDriverNotConfigured;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||
|
||||
class SocialDriverManager
|
||||
{
|
||||
/**
|
||||
* The default built-in social drivers we support.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $validDrivers = [
|
||||
'google',
|
||||
'github',
|
||||
'facebook',
|
||||
'slack',
|
||||
'twitter',
|
||||
'azure',
|
||||
'okta',
|
||||
'gitlab',
|
||||
'twitch',
|
||||
'discord',
|
||||
];
|
||||
|
||||
/**
|
||||
* Callbacks to run when configuring a social driver
|
||||
* for an initial redirect action.
|
||||
* Array is keyed by social driver name.
|
||||
* Callbacks are passed an instance of the driver.
|
||||
*
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
protected array $configureForRedirectCallbacks = [];
|
||||
|
||||
/**
|
||||
* Check if the current config for the given driver allows auto-registration.
|
||||
*/
|
||||
public function isAutoRegisterEnabled(string $driver): bool
|
||||
{
|
||||
return $this->getDriverConfigProperty($driver, 'auto_register') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current config for the given driver allow email address auto-confirmation.
|
||||
*/
|
||||
public function isAutoConfirmEmailEnabled(string $driver): bool
|
||||
{
|
||||
return $this->getDriverConfigProperty($driver, 'auto_confirm') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the names of the active social drivers, keyed by driver id.
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getActive(): array
|
||||
{
|
||||
$activeDrivers = [];
|
||||
|
||||
foreach ($this->validDrivers as $driverKey) {
|
||||
if ($this->checkDriverConfigured($driverKey)) {
|
||||
$activeDrivers[$driverKey] = $this->getName($driverKey);
|
||||
}
|
||||
}
|
||||
|
||||
return $activeDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configure-for-redirect callback for the given driver.
|
||||
* This is a callable that allows modification of the driver at redirect time.
|
||||
* Commonly used to perform custom dynamic configuration where required.
|
||||
* The callback is passed a \Laravel\Socialite\Contracts\Provider instance.
|
||||
*/
|
||||
public function getConfigureForRedirectCallback(string $driver): callable
|
||||
{
|
||||
return $this->configureForRedirectCallbacks[$driver] ?? (fn() => true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom socialite driver to be used.
|
||||
* Driver name should be lower_snake_case.
|
||||
* Config array should mirror the structure of a service
|
||||
* within the `Config/services.php` file.
|
||||
* Handler should be a Class@method handler to the SocialiteWasCalled event.
|
||||
*/
|
||||
public function addSocialDriver(
|
||||
string $driverName,
|
||||
array $config,
|
||||
string $socialiteHandler,
|
||||
?callable $configureForRedirect = null
|
||||
) {
|
||||
$this->validDrivers[] = $driverName;
|
||||
config()->set('services.' . $driverName, $config);
|
||||
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
|
||||
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
|
||||
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
|
||||
if (!is_null($configureForRedirect)) {
|
||||
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the presentational name for a driver.
|
||||
*/
|
||||
protected function getName(string $driver): string
|
||||
{
|
||||
return $this->getDriverConfigProperty($driver, 'name') ?? '';
|
||||
}
|
||||
|
||||
protected function getDriverConfigProperty(string $driver, string $property): mixed
|
||||
{
|
||||
return config("services.{$driver}.{$property}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the social driver is correct and supported.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
*/
|
||||
public function ensureDriverActive(string $driverName): void
|
||||
{
|
||||
if (!in_array($driverName, $this->validDrivers)) {
|
||||
abort(404, trans('errors.social_driver_not_found'));
|
||||
}
|
||||
|
||||
if (!$this->checkDriverConfigured($driverName)) {
|
||||
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($driverName)]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a social driver has been configured correctly.
|
||||
*/
|
||||
protected function checkDriverConfigured(string $driver): bool
|
||||
{
|
||||
$lowerName = strtolower($driver);
|
||||
$configPrefix = 'services.' . $lowerName . '.';
|
||||
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
|
||||
|
||||
return !in_array(false, $config) && !in_array(null, $config);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use Exception;
|
||||
|
||||
class UserInviteException extends Exception
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Access\Notifications\UserInviteNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
class UserInviteService extends UserTokenService
|
||||
{
|
||||
protected string $tokenTable = 'user_invites';
|
||||
protected int $expiryTime = 336; // Two weeks
|
||||
|
||||
/**
|
||||
* Send an invitation to a user to sign into BookStack
|
||||
* Removes existing invitation tokens.
|
||||
* @throws UserInviteException
|
||||
*/
|
||||
public function sendInvitation(User $user)
|
||||
{
|
||||
$this->deleteByUser($user);
|
||||
$token = $this->createTokenForUser($user);
|
||||
|
||||
try {
|
||||
$user->notify(new UserInviteNotification($token));
|
||||
} catch (\Exception $exception) {
|
||||
throw new UserInviteException($exception->getMessage(), $exception->getCode(), $exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
app/Actions/Activity.php
Normal file
68
app/Actions/Activity.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property string $type
|
||||
* @property User $user
|
||||
* @property Entity $entity
|
||||
* @property string $detail
|
||||
* @property string $entity_type
|
||||
* @property int $entity_id
|
||||
* @property int $user_id
|
||||
*/
|
||||
class Activity extends Model
|
||||
{
|
||||
/**
|
||||
* Get the entity for this activity.
|
||||
*/
|
||||
public function entity(): MorphTo
|
||||
{
|
||||
if ($this->entity_type === '') {
|
||||
$this->entity_type = null;
|
||||
}
|
||||
|
||||
return $this->morphTo('entity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user this activity relates to.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns text from the language files, Looks up by using the activity key.
|
||||
*/
|
||||
public function getText(): string
|
||||
{
|
||||
return trans('activities.' . $this->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this activity is intended to be for an entity.
|
||||
*/
|
||||
public function isForEntity(): bool
|
||||
{
|
||||
return Str::startsWith($this->type, [
|
||||
'page_', 'chapter_', 'book_', 'bookshelf_',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if another Activity matches the general information of another.
|
||||
*/
|
||||
public function isSimilarTo(self $activityB): bool
|
||||
{
|
||||
return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -1,25 +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\MorphTo;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,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);
|
||||
}
|
||||
|
||||
@@ -60,15 +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) {
|
||||
/** @var MorphTo<Entity, Activity> $query */
|
||||
->with(['entity' => function (Relation $query) {
|
||||
$query->withTrashed();
|
||||
}, 'user.avatar'])
|
||||
->skip($count * ($page - 1))
|
||||
@@ -83,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();
|
||||
@@ -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';
|
||||
@@ -67,14 +63,6 @@ class ActivityType
|
||||
const WEBHOOK_UPDATE = 'webhook_update';
|
||||
const WEBHOOK_DELETE = 'webhook_delete';
|
||||
|
||||
const IMPORT_CREATE = 'import_create';
|
||||
const IMPORT_RUN = 'import_run';
|
||||
const IMPORT_DELETE = 'import_delete';
|
||||
|
||||
const SORT_RULE_CREATE = 'sort_rule_create';
|
||||
const SORT_RULE_UPDATE = 'sort_rule_update';
|
||||
const SORT_RULE_DELETE = 'sort_rule_delete';
|
||||
|
||||
/**
|
||||
* Get all the possible values.
|
||||
*/
|
||||
60
app/Actions/Comment.php
Normal file
60
app/Actions/Comment.php
Normal 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();
|
||||
}
|
||||
}
|
||||
98
app/Actions/CommentRepo.php
Normal file
98
app/Actions/CommentRepo.php
Normal 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;
|
||||
}
|
||||
}
|
||||
82
app/Actions/DispatchWebhookJob.php
Normal file
82
app/Actions/DispatchWebhookJob.php
Normal 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
19
app/Actions/Favourite.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Tools;
|
||||
namespace BookStack\Actions;
|
||||
|
||||
class IpFormatter
|
||||
{
|
||||
@@ -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;
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $value
|
||||
* @property int $entity_id
|
||||
* @property string $entity_type
|
||||
* @property int $order
|
||||
*/
|
||||
class Tag extends Model
|
||||
@@ -31,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.
|
||||
*/
|
||||
49
app/Actions/TagClassGenerator.php
Normal file
49
app/Actions/TagClassGenerator.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
class TagClassGenerator
|
||||
{
|
||||
protected array $tags;
|
||||
|
||||
/**
|
||||
* @param Tag[] $tags
|
||||
*/
|
||||
public function __construct(array $tags)
|
||||
{
|
||||
$this->tags = $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function generate(): array
|
||||
{
|
||||
$classes = [];
|
||||
|
||||
foreach ($this->tags as $tag) {
|
||||
$name = $this->normalizeTagClassString($tag->name);
|
||||
$value = $this->normalizeTagClassString($tag->value);
|
||||
$classes[] = 'tag-name-' . $name;
|
||||
if ($value) {
|
||||
$classes[] = 'tag-value-' . $value;
|
||||
$classes[] = 'tag-pair-' . $name . '-' . $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($classes);
|
||||
}
|
||||
|
||||
public function generateAsString(): string
|
||||
{
|
||||
return implode(' ', $this->generate());
|
||||
}
|
||||
|
||||
protected function normalizeTagClassString(string $value): string
|
||||
{
|
||||
$value = str_replace(' ', '', strtolower($value));
|
||||
$value = str_replace('-', '', strtolower($value));
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -50,7 +52,7 @@ class WebhookFormatter
|
||||
}
|
||||
|
||||
if ($this->detail instanceof Model) {
|
||||
$data['related_item'] = $this->formatModel($this->detail);
|
||||
$data['related_item'] = $this->formatModel();
|
||||
}
|
||||
|
||||
return $data;
|
||||
@@ -83,8 +85,10 @@ class WebhookFormatter
|
||||
);
|
||||
}
|
||||
|
||||
protected function formatModel(Model $model): array
|
||||
protected function formatModel(): array
|
||||
{
|
||||
/** @var Model $model */
|
||||
$model = $this->detail;
|
||||
$model->unsetRelations();
|
||||
|
||||
foreach ($this->modelFormatters as $formatter) {
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Models;
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -1,153 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity;
|
||||
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Facades\Activity as ActivityService;
|
||||
use BookStack\Util\HtmlDescriptionFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class CommentRepo
|
||||
{
|
||||
/**
|
||||
* Get a comment by ID.
|
||||
*/
|
||||
public function getById(int $id): Comment
|
||||
{
|
||||
return Comment::query()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a comment by ID, ensuring it is visible to the user based upon access to the page
|
||||
* which the comment is attached to.
|
||||
*/
|
||||
public function getVisibleById(int $id): Comment
|
||||
{
|
||||
return $this->getQueryForVisible()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query for comments visible to the user.
|
||||
* @return Builder<Comment>
|
||||
*/
|
||||
public function getQueryForVisible(): Builder
|
||||
{
|
||||
return Comment::query()->scopes('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment on an entity.
|
||||
*/
|
||||
public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
|
||||
{
|
||||
// Prevent comments being added to draft pages
|
||||
if ($entity instanceof Page && $entity->draft) {
|
||||
throw new \Exception(trans('errors.cannot_add_comment_to_draft'));
|
||||
}
|
||||
|
||||
// Validate parent ID
|
||||
if ($parentId !== null) {
|
||||
$parentCommentExists = Comment::query()
|
||||
->where('commentable_id', '=', $entity->id)
|
||||
->where('commentable_type', '=', $entity->getMorphClass())
|
||||
->where('local_id', '=', $parentId)
|
||||
->exists();
|
||||
if (!$parentCommentExists) {
|
||||
$parentId = null;
|
||||
}
|
||||
}
|
||||
|
||||
$userId = user()->id;
|
||||
$comment = new Comment();
|
||||
|
||||
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||
$comment->created_by = $userId;
|
||||
$comment->updated_by = $userId;
|
||||
$comment->local_id = $this->getNextLocalId($entity);
|
||||
$comment->parent_id = $parentId;
|
||||
$comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $contentRef) === 1 ? $contentRef : '';
|
||||
|
||||
$entity->comments()->save($comment);
|
||||
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
|
||||
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
||||
|
||||
$comment->refresh()->unsetRelations();
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing comment.
|
||||
*/
|
||||
public function update(Comment $comment, string $html): Comment
|
||||
{
|
||||
$comment->updated_by = user()->id;
|
||||
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||
$comment->save();
|
||||
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Archive an existing comment.
|
||||
*/
|
||||
public function archive(Comment $comment, bool $log = true): Comment
|
||||
{
|
||||
if ($comment->parent_id) {
|
||||
throw new NotifyException('Only top-level comments can be archived.', '/', 400);
|
||||
}
|
||||
|
||||
$comment->archived = true;
|
||||
$comment->save();
|
||||
|
||||
if ($log) {
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
}
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-archive an existing comment.
|
||||
*/
|
||||
public function unarchive(Comment $comment, bool $log = true): Comment
|
||||
{
|
||||
if ($comment->parent_id) {
|
||||
throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
|
||||
}
|
||||
|
||||
$comment->archived = false;
|
||||
$comment->save();
|
||||
|
||||
if ($log) {
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
}
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment from the system.
|
||||
*/
|
||||
public function delete(Comment $comment): void
|
||||
{
|
||||
$comment->delete();
|
||||
|
||||
ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next local ID relative to the linked entity.
|
||||
*/
|
||||
protected function getNextLocalId(Entity $entity): int
|
||||
{
|
||||
$currentMaxId = $entity->comments()->max('local_id');
|
||||
|
||||
return $currentMaxId + 1;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Permissions\Permission;
|
||||
|
||||
class AuditLogApiController extends ApiController
|
||||
{
|
||||
/**
|
||||
* Get a listing of audit log events in the system.
|
||||
* The loggable relation fields currently only relates to core
|
||||
* content types (page, book, bookshelf, chapter) but this may be
|
||||
* used more in the future across other types.
|
||||
* Requires permission to manage both users and system settings.
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$this->checkPermission(Permission::SettingsManage);
|
||||
$this->checkPermission(Permission::UsersManage);
|
||||
|
||||
$query = Activity::query()->with(['user']);
|
||||
|
||||
return $this->apiListingResponse($query, [
|
||||
'id', 'type', 'detail', 'user_id', 'loggable_id', 'loggable_type', 'ip', 'created_at',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\CommentRepo;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* The comment data model has a 'local_id' property, which is a unique integer ID
|
||||
* scoped to the page which the comment is on. The 'parent_id' is used for replies
|
||||
* and refers to the 'local_id' of the parent comment on the same page, not the main
|
||||
* globally unique 'id'.
|
||||
*
|
||||
* If you want to get all comments for a page in a tree-like structure, as reflected in
|
||||
* the UI, then that is provided on pages-read API responses.
|
||||
*/
|
||||
class CommentApiController extends ApiController
|
||||
{
|
||||
protected array $rules = [
|
||||
'create' => [
|
||||
'page_id' => ['required', 'integer'],
|
||||
'reply_to' => ['nullable', 'integer'],
|
||||
'html' => ['required', 'string'],
|
||||
'content_ref' => ['string'],
|
||||
],
|
||||
'update' => [
|
||||
'html' => ['string'],
|
||||
'archived' => ['boolean'],
|
||||
]
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected CommentRepo $commentRepo,
|
||||
protected PageQueries $pageQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a listing of comments visible to the user.
|
||||
*/
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
$query = $this->commentRepo->getQueryForVisible();
|
||||
|
||||
return $this->apiListingResponse($query, [
|
||||
'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment on a page.
|
||||
* If commenting as a reply to an existing comment, the 'reply_to' parameter
|
||||
* should be provided, set to the 'local_id' of the comment being replied to.
|
||||
*/
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$this->checkPermission(Permission::CommentCreateAll);
|
||||
|
||||
$input = $this->validate($request, $this->rules()['create']);
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']);
|
||||
|
||||
$comment = $this->commentRepo->create(
|
||||
$page,
|
||||
$input['html'],
|
||||
$input['reply_to'] ?? null,
|
||||
$input['content_ref'] ?? '',
|
||||
);
|
||||
|
||||
return response()->json($comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the details of a single comment, along with its direct replies.
|
||||
*/
|
||||
public function read(string $id): JsonResponse
|
||||
{
|
||||
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||
$comment->load('createdBy', 'updatedBy');
|
||||
|
||||
$replies = $this->commentRepo->getQueryForVisible()
|
||||
->where('parent_id', '=', $comment->local_id)
|
||||
->where('commentable_id', '=', $comment->commentable_id)
|
||||
->where('commentable_type', '=', $comment->commentable_type)
|
||||
->get();
|
||||
|
||||
/** @var Comment[] $toProcess */
|
||||
$toProcess = [$comment, ...$replies];
|
||||
foreach ($toProcess as $commentToProcess) {
|
||||
$commentToProcess->setAttribute('html', $commentToProcess->safeHtml());
|
||||
$commentToProcess->makeVisible('html');
|
||||
}
|
||||
|
||||
$comment->setRelation('replies', $replies);
|
||||
|
||||
return response()->json($comment);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the content or archived status of an existing comment.
|
||||
*
|
||||
* Only provide a new archived status if needing to actively change the archive state.
|
||||
* Only top-level comments (non-replies) can be archived or unarchived.
|
||||
*/
|
||||
public function update(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
|
||||
|
||||
$input = $this->validate($request, $this->rules()['update']);
|
||||
$hasHtml = isset($input['html']);
|
||||
|
||||
if (isset($input['archived'])) {
|
||||
if ($input['archived']) {
|
||||
$this->commentRepo->archive($comment, !$hasHtml);
|
||||
} else {
|
||||
$this->commentRepo->unarchive($comment, !$hasHtml);
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasHtml) {
|
||||
$comment = $this->commentRepo->update($comment, $input['html']);
|
||||
}
|
||||
|
||||
return response()->json($comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single comment from the system.
|
||||
*/
|
||||
public function delete(string $id): Response
|
||||
{
|
||||
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
|
||||
|
||||
$this->commentRepo->delete($comment);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\CommentRepo;
|
||||
use BookStack\Activity\Tools\CommentTree;
|
||||
use BookStack\Activity\Tools\CommentTreeNode;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected CommentRepo $commentRepo,
|
||||
protected PageQueries $pageQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new comment for a Page.
|
||||
*
|
||||
* @throws ValidationException|\Exception
|
||||
*/
|
||||
public function savePageComment(Request $request, int $pageId)
|
||||
{
|
||||
$input = $this->validate($request, [
|
||||
'html' => ['required', 'string'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
'content_ref' => ['string'],
|
||||
]);
|
||||
|
||||
$page = $this->pageQueries->findVisibleById($pageId);
|
||||
if ($page === null) {
|
||||
return response('Not found', 404);
|
||||
}
|
||||
|
||||
// Create a new comment.
|
||||
$this->checkPermission(Permission::CommentCreateAll);
|
||||
$contentRef = $input['content_ref'] ?? '';
|
||||
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef);
|
||||
|
||||
return view('comments.comment-branch', [
|
||||
'readOnly' => false,
|
||||
'branch' => new CommentTreeNode($comment, 0, []),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing comment.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(Request $request, int $commentId)
|
||||
{
|
||||
$input = $this->validate($request, [
|
||||
'html' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$comment = $this->commentRepo->getById($commentId);
|
||||
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
|
||||
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
|
||||
|
||||
$comment = $this->commentRepo->update($comment, $input['html']);
|
||||
|
||||
return view('comments.comment', [
|
||||
'comment' => $comment,
|
||||
'readOnly' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a comment as archived.
|
||||
*/
|
||||
public function archive(int $id)
|
||||
{
|
||||
$comment = $this->commentRepo->getById($id);
|
||||
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
|
||||
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
$this->commentRepo->archive($comment);
|
||||
|
||||
$tree = new CommentTree($comment->entity);
|
||||
return view('comments.comment-branch', [
|
||||
'readOnly' => false,
|
||||
'branch' => $tree->getCommentNodeForId($id),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmark a comment as archived.
|
||||
*/
|
||||
public function unarchive(int $id)
|
||||
{
|
||||
$comment = $this->commentRepo->getById($id);
|
||||
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
|
||||
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
$this->commentRepo->unarchive($comment);
|
||||
|
||||
$tree = new CommentTree($comment->entity);
|
||||
return view('comments.comment-branch', [
|
||||
'readOnly' => false,
|
||||
'branch' => $tree->getCommentNodeForId($id),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment from the system.
|
||||
*/
|
||||
public function destroy(int $id)
|
||||
{
|
||||
$comment = $this->commentRepo->getById($id);
|
||||
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
|
||||
|
||||
$this->commentRepo->delete($comment);
|
||||
|
||||
return response()->json(['message' => trans('entities.comment_deleted')]);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\QueryTopFavourites;
|
||||
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class FavouriteController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected MixedEntityRequestHelper $entityHelper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a listing of all favourite items for the current user.
|
||||
*/
|
||||
public function index(Request $request, QueryTopFavourites $topFavourites)
|
||||
{
|
||||
$viewCount = 20;
|
||||
$page = intval($request->get('page', 1));
|
||||
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
|
||||
|
||||
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
|
||||
|
||||
$this->setPageTitle(trans('entities.my_favourites'));
|
||||
|
||||
return view('common.detailed-listing-with-more', [
|
||||
'title' => trans('entities.my_favourites'),
|
||||
'entities' => $favourites->slice(0, $viewCount),
|
||||
'hasMoreLink' => $hasMoreLink,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new item as a favourite.
|
||||
*/
|
||||
public function add(Request $request)
|
||||
{
|
||||
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
|
||||
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
|
||||
$entity->favourites()->firstOrCreate([
|
||||
'user_id' => user()->id,
|
||||
]);
|
||||
|
||||
$this->showSuccessNotification(trans('activities.favourite_add_notification', [
|
||||
'name' => $entity->name,
|
||||
]));
|
||||
|
||||
return redirect($entity->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item as a favourite.
|
||||
*/
|
||||
public function remove(Request $request)
|
||||
{
|
||||
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
|
||||
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
|
||||
$entity->favourites()->where([
|
||||
'user_id' => user()->id,
|
||||
])->delete();
|
||||
|
||||
$this->showSuccessNotification(trans('activities.favourite_remove_notification', [
|
||||
'name' => $entity->name,
|
||||
]));
|
||||
|
||||
return redirect($entity->getUrl());
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WatchController extends Controller
|
||||
{
|
||||
public function update(Request $request, MixedEntityRequestHelper $entityHelper)
|
||||
{
|
||||
$this->checkPermission(Permission::ReceiveNotifications);
|
||||
$this->preventGuestAccess();
|
||||
|
||||
$requestData = $this->validate($request, array_merge([
|
||||
'level' => ['required', 'string'],
|
||||
], $entityHelper->validationRules()));
|
||||
|
||||
$watchable = $entityHelper->getVisibleEntityFromRequestData($requestData);
|
||||
$watchOptions = new UserEntityWatchOptions(user(), $watchable);
|
||||
$watchOptions->updateLevelByName($requestData['level']);
|
||||
|
||||
$this->showSuccessNotification(trans('activities.watch_update_level_notification'));
|
||||
|
||||
return redirect($watchable->getUrl());
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Models\Webhook;
|
||||
use BookStack\Activity\Tools\WebhookFormatter;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Users\Models\User;
|
||||
use BookStack\Util\SsrUrlValidator;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DispatchWebhookJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
protected Webhook $webhook;
|
||||
protected User $initiator;
|
||||
protected int $initiatedTime;
|
||||
protected array $webhookData;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Webhook $webhook, string $event, Loggable|string $detail)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
$this->initiator = user();
|
||||
$this->initiatedTime = time();
|
||||
|
||||
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $event, $this->webhook, $detail, $this->initiator, $this->initiatedTime);
|
||||
$this->webhookData = $themeResponse ?? WebhookFormatter::getDefault($event, $this->webhook, $detail, $this->initiator, $this->initiatedTime)->format();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle(HttpRequestService $http)
|
||||
{
|
||||
$lastError = null;
|
||||
|
||||
try {
|
||||
(new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint);
|
||||
|
||||
$client = $http->buildClient($this->webhook->timeout, [
|
||||
'connect_timeout' => 10,
|
||||
'allow_redirects' => ['strict' => true],
|
||||
]);
|
||||
|
||||
$response = $client->sendRequest($http->jsonRequest('POST', $this->webhook->endpoint, $this->webhookData));
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
if ($statusCode >= 400) {
|
||||
$lastError = "Response status from endpoint was {$statusCode}";
|
||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$statusCode}");
|
||||
}
|
||||
} catch (\Exception $error) {
|
||||
$lastError = $error->getMessage();
|
||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
|
||||
}
|
||||
|
||||
$this->webhook->last_called_at = now();
|
||||
if ($lastError) {
|
||||
$this->webhook->last_errored_at = now();
|
||||
$this->webhook->last_error = $lastError;
|
||||
}
|
||||
|
||||
$this->webhook->save();
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property string $type
|
||||
* @property User $user
|
||||
* @property Entity $loggable
|
||||
* @property string $detail
|
||||
* @property string $loggable_type
|
||||
* @property int $loggable_id
|
||||
* @property int $user_id
|
||||
* @property Carbon $created_at
|
||||
*/
|
||||
class Activity extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Get the loggable model related to this activity.
|
||||
* Currently only used for entities (previously entity_[id/type] columns).
|
||||
* Could be used for others but will need an audit of uses where assumed
|
||||
* to be entities.
|
||||
*/
|
||||
public function loggable(): MorphTo
|
||||
{
|
||||
return $this->morphTo('loggable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user this activity relates to.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'loggable_id')
|
||||
->whereColumn('activities.loggable_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns text from the language files, Looks up by using the activity key.
|
||||
*/
|
||||
public function getText(): string
|
||||
{
|
||||
return trans('activities.' . $this->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this activity is intended to be for an entity.
|
||||
*/
|
||||
public function isForEntity(): bool
|
||||
{
|
||||
return Str::startsWith($this->type, [
|
||||
'page_', 'chapter_', 'book_', 'bookshelf_',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if another Activity matches the general information of another.
|
||||
*/
|
||||
public function isSimilarTo(self $activityB): bool
|
||||
{
|
||||
return [$this->type, $this->loggable_type, $this->loggable_id] === [$activityB->type, $activityB->loggable_type, $activityB->loggable_id];
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use BookStack\Users\Models\OwnableInterface;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $html
|
||||
* @property int|null $parent_id - Relates to local_id, not id
|
||||
* @property int $local_id
|
||||
* @property string $commentable_type
|
||||
* @property int $commentable_id
|
||||
* @property string $content_ref
|
||||
* @property bool $archived
|
||||
*/
|
||||
class Comment extends Model implements Loggable, OwnableInterface
|
||||
{
|
||||
use HasFactory;
|
||||
use HasCreatorAndUpdater;
|
||||
|
||||
protected $fillable = ['parent_id'];
|
||||
protected $hidden = ['html'];
|
||||
|
||||
protected $casts = [
|
||||
'archived' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the entity that this comment belongs to.
|
||||
*/
|
||||
public function entity(): MorphTo
|
||||
{
|
||||
// We specifically define null here to avoid the different name (commentable)
|
||||
// being used by Laravel eager loading instead of the method name, which it was doing
|
||||
// in some scenarios like when deserialized when going through the queue system.
|
||||
// So we instead specify the type and id column names to use.
|
||||
// Related to:
|
||||
// https://github.com/laravel/framework/pull/24815
|
||||
// https://github.com/laravel/framework/issues/27342
|
||||
// https://github.com/laravel/framework/issues/47953
|
||||
// (and probably more)
|
||||
|
||||
// Ultimately, we could just align the method name to 'commentable' but that would be a potential
|
||||
// breaking change and not really worthwhile in a patch due to the risk of creating extra problems.
|
||||
return $this->morphTo(null, 'commentable_type', 'commentable_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent comment this is in reply to (if existing).
|
||||
* @return BelongsTo<Comment, $this>
|
||||
*/
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
|
||||
->where('commentable_type', '=', $this->commentable_type)
|
||||
->where('commentable_id', '=', $this->commentable_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a comment has been updated since creation.
|
||||
*/
|
||||
public function isUpdated(): bool
|
||||
{
|
||||
return $this->updated_at->timestamp > $this->created_at->timestamp;
|
||||
}
|
||||
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})";
|
||||
}
|
||||
|
||||
public function safeHtml(): string
|
||||
{
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id')
|
||||
->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope the query to just the comments visible to the user based upon the
|
||||
* user visibility of what has been commented on.
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return app()->make(PermissionApplicator::class)
|
||||
->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type');
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class Favourite extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $mentionable_type
|
||||
* @property int $mentionable_id
|
||||
* @property int $from_user_id
|
||||
* @property int $to_user_id
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class MentionHistory extends Model
|
||||
{
|
||||
protected $table = 'mention_history';
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\Activity\WatchLevels;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
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
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +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\Permission;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
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();
|
||||
|
||||
/** @var User $user */
|
||||
foreach ($users as $user) {
|
||||
// Prevent sending to the user that initiated the activity
|
||||
if ($user->id === $initiator->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prevent sending of the user does not have notification permissions
|
||||
if (!$user->can(Permission::ReceiveNotifications)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prevent sending if the user does not have access to the related content
|
||||
$permissions = new PermissionApplicator($user);
|
||||
if (!$permissions->checkOwnableUserAccess($relatedModel, 'view')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send the notification
|
||||
try {
|
||||
$user->notify(new $notification($detail, $initiator));
|
||||
} catch (\Exception $exception) {
|
||||
Log::error("Failed to send email notification to user [id:{$user->id}] with error: {$exception->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ($page->owned_by && !$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
|
||||
$userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
|
||||
if ($userNotificationPrefs->notifyOnOwnPageComments()) {
|
||||
$watcherIds[] = $page->owned_by;
|
||||
}
|
||||
}
|
||||
|
||||
// Parent comment creator if preferences allow
|
||||
$parentComment = $detail->parent()->first();
|
||||
if ($parentComment && $parentComment->created_by && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
|
||||
$parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
|
||||
if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
|
||||
$watcherIds[] = $parentComment->created_by;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendNotificationToUserIds(CommentCreationNotification::class, $watcherIds, $user, $detail, $page);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Handlers;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Models\MentionHistory;
|
||||
use BookStack\Activity\Notifications\Messages\CommentMentionNotification;
|
||||
use BookStack\Activity\Tools\MentionParser;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Settings\UserNotificationPreferences;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class CommentMentionNotificationHandler extends BaseNotificationHandler
|
||||
{
|
||||
public function handle(Activity $activity, Loggable|string $detail, User $user): void
|
||||
{
|
||||
if (!($detail instanceof Comment) || !($detail->entity instanceof Page)) {
|
||||
throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page");
|
||||
}
|
||||
|
||||
/** @var Page $page */
|
||||
$page = $detail->entity;
|
||||
|
||||
$parser = new MentionParser();
|
||||
$mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html);
|
||||
$realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get();
|
||||
|
||||
$receivingNotifications = $realMentionedUsers->filter(function (User $user) {
|
||||
$prefs = new UserNotificationPreferences($user);
|
||||
return $prefs->notifyOnCommentMentions();
|
||||
});
|
||||
$receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray();
|
||||
|
||||
$userMentionsToLog = $realMentionedUsers;
|
||||
|
||||
// When an edit, we check our history to see if we've already notified the user about this comment before
|
||||
// so that we can filter them out to avoid double notifications.
|
||||
if ($activity->type === ActivityType::COMMENT_UPDATE) {
|
||||
$previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail);
|
||||
$receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds));
|
||||
$userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) {
|
||||
return !in_array($user->id, $previouslyNotifiedUserIds);
|
||||
});
|
||||
}
|
||||
|
||||
$this->logMentions($userMentionsToLog, $detail, $user);
|
||||
$this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<User> $mentionedUsers
|
||||
*/
|
||||
protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void
|
||||
{
|
||||
$mentions = [];
|
||||
$now = Carbon::now();
|
||||
|
||||
foreach ($mentionedUsers as $mentionedUser) {
|
||||
$mentions[] = [
|
||||
'mentionable_type' => $comment->getMorphClass(),
|
||||
'mentionable_id' => $comment->id,
|
||||
'from_user_id' => $fromUser->id,
|
||||
'to_user_id' => $mentionedUser->id,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
MentionHistory::query()->insert($mentions);
|
||||
}
|
||||
|
||||
protected function getPreviouslyNotifiedUserIds(Comment $comment): array
|
||||
{
|
||||
return MentionHistory::query()
|
||||
->where('mentionable_id', $comment->id)
|
||||
->where('mentionable_type', $comment->getMorphClass())
|
||||
->pluck('to_user_id')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +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 the last update from activity
|
||||
/** @var ?Activity $lastUpdate */
|
||||
$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 the page owner if preferences allow
|
||||
if ($detail->owned_by && !$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
|
||||
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
|
||||
if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
|
||||
$watcherIds[] = $detail->owned_by;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendNotificationToUserIds(PageUpdateNotification::class, $watcherIds, $user, $detail, $detail);
|
||||
}
|
||||
}
|
||||
@@ -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()})";
|
||||
}
|
||||
}
|
||||
@@ -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(' > ', $entityHtmls);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return implode(' > ', $this->entityLinks);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 CommentMentionNotification 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.comment_mention_subject', ['pageName' => $page->getShortName()]))
|
||||
->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')]))
|
||||
->line(new ListMessageLine($listLines))
|
||||
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
|
||||
->line($this->buildReasonFooterLine($locale));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,55 +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\CommentMentionNotificationHandler;
|
||||
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);
|
||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class);
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Tools;
|
||||
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Permissions\Permission;
|
||||
|
||||
class CommentTree
|
||||
{
|
||||
/**
|
||||
* The built nested tree structure array.
|
||||
* @var CommentTreeNode[]
|
||||
*/
|
||||
protected array $tree;
|
||||
|
||||
/**
|
||||
* A linear array of loaded comments.
|
||||
* @var Comment[]
|
||||
*/
|
||||
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->getActive()) === 0;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->comments);
|
||||
}
|
||||
|
||||
public function getActive(): array
|
||||
{
|
||||
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived));
|
||||
}
|
||||
|
||||
public function activeThreadCount(): int
|
||||
{
|
||||
return count($this->getActive());
|
||||
}
|
||||
|
||||
public function getArchived(): array
|
||||
{
|
||||
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived));
|
||||
}
|
||||
|
||||
public function archivedThreadCount(): int
|
||||
{
|
||||
return count($this->getArchived());
|
||||
}
|
||||
|
||||
public function getCommentNodeForId(int $commentId): ?CommentTreeNode
|
||||
{
|
||||
foreach ($this->tree as $node) {
|
||||
if ($node->comment->id === $commentId) {
|
||||
return $node;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function canUpdateAny(): bool
|
||||
{
|
||||
foreach ($this->comments as $comment) {
|
||||
if (userCan(Permission::CommentUpdate, $comment)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function loadVisibleHtml(): void
|
||||
{
|
||||
foreach ($this->comments as $comment) {
|
||||
$comment->setAttribute('html', $comment->safeHtml());
|
||||
$comment->makeVisible('html');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Comment[] $comments
|
||||
* @return CommentTreeNode[]
|
||||
*/
|
||||
protected function createTree(array $comments): array
|
||||
{
|
||||
$byId = [];
|
||||
foreach ($comments as $comment) {
|
||||
$byId[$comment->local_id] = $comment;
|
||||
}
|
||||
|
||||
$childMap = [];
|
||||
foreach ($comments as $comment) {
|
||||
$parent = $comment->parent_id;
|
||||
if (is_null($parent) || !isset($byId[$parent])) {
|
||||
$parent = 0;
|
||||
}
|
||||
|
||||
if (!isset($childMap[$parent])) {
|
||||
$childMap[$parent] = [];
|
||||
}
|
||||
$childMap[$parent][] = $comment->local_id;
|
||||
}
|
||||
|
||||
$tree = [];
|
||||
foreach ($childMap[0] ?? [] as $childId) {
|
||||
$tree[] = $this->createTreeNodeForId($childId, 0, $byId, $childMap);
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
protected function createTreeNodeForId(int $id, int $depth, array &$byId, array &$childMap): CommentTreeNode
|
||||
{
|
||||
$childIds = $childMap[$id] ?? [];
|
||||
$children = [];
|
||||
|
||||
foreach ($childIds as $childId) {
|
||||
$children[] = $this->createTreeNodeForId($childId, $depth + 1, $byId, $childMap);
|
||||
}
|
||||
|
||||
return new CommentTreeNode($byId[$id], $depth, $children);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Comment[]
|
||||
*/
|
||||
protected function loadComments(): array
|
||||
{
|
||||
if (!$this->enabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->page->comments()
|
||||
->with('createdBy')
|
||||
->get()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Tools;
|
||||
|
||||
use BookStack\Activity\Models\Comment;
|
||||
|
||||
class CommentTreeNode
|
||||
{
|
||||
public Comment $comment;
|
||||
public int $depth;
|
||||
|
||||
/**
|
||||
* @var CommentTreeNode[]
|
||||
*/
|
||||
public array $children;
|
||||
|
||||
public function __construct(Comment $comment, int $depth, array $children)
|
||||
{
|
||||
$this->comment = $comment;
|
||||
$this->depth = $depth;
|
||||
$this->children = $children;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Tools;
|
||||
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use DOMElement;
|
||||
|
||||
class MentionParser
|
||||
{
|
||||
public function parseUserIdsFromHtml(string $html): array
|
||||
{
|
||||
$doc = new HtmlDocument($html);
|
||||
|
||||
$ids = [];
|
||||
$mentionLinks = $doc->queryXPath('//a[@data-mention-user-id]');
|
||||
|
||||
foreach ($mentionLinks as $link) {
|
||||
if ($link instanceof DOMElement) {
|
||||
$id = intval($link->getAttribute('data-mention-user-id'));
|
||||
if ($id > 0) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Tools;
|
||||
|
||||
use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Permissions\Permission;
|
||||
|
||||
class TagClassGenerator
|
||||
{
|
||||
public function __construct(
|
||||
protected Entity $entity
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function generate(): array
|
||||
{
|
||||
$classes = [];
|
||||
$tags = $this->entity->tags->all();
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
array_push($classes, ...$this->generateClassesForTag($tag));
|
||||
}
|
||||
|
||||
if ($this->entity instanceof BookChild && userCan(Permission::BookView, $this->entity->book)) {
|
||||
$bookTags = $this->entity->book->tags;
|
||||
foreach ($bookTags as $bookTag) {
|
||||
array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-'));
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->entity instanceof Page && $this->entity->chapter && userCan(Permission::ChapterView, $this->entity->chapter)) {
|
||||
$chapterTags = $this->entity->chapter->tags;
|
||||
foreach ($chapterTags as $chapterTag) {
|
||||
array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-'));
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($classes);
|
||||
}
|
||||
|
||||
public function generateAsString(): string
|
||||
{
|
||||
return implode(' ', $this->generate());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
protected function generateClassesForTag(Tag $tag, string $prefix = ''): array
|
||||
{
|
||||
$classes = [];
|
||||
$name = $this->normalizeTagClassString($tag->name);
|
||||
$value = $this->normalizeTagClassString($tag->value);
|
||||
$classes[] = "{$prefix}tag-name-{$name}";
|
||||
if ($value) {
|
||||
$classes[] = "{$prefix}tag-value-{$value}";
|
||||
$classes[] = "{$prefix}tag-pair-{$name}-{$value}";
|
||||
}
|
||||
return $classes;
|
||||
}
|
||||
|
||||
protected function normalizeTagClassString(string $value): string
|
||||
{
|
||||
$value = str_replace(' ', '', strtolower($value));
|
||||
$value = str_replace('-', '', strtolower($value));
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -1,132 +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\Permissions\Permission;
|
||||
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(Permission::ReceiveNotifications) && !$this->user->isGuest();
|
||||
}
|
||||
|
||||
public function getWatchLevel(): string
|
||||
{
|
||||
return WatchLevels::levelValueToName($this->getWatchLevelValue());
|
||||
}
|
||||
|
||||
public function isWatching(): bool
|
||||
{
|
||||
return $this->getWatchLevelValue() !== WatchLevels::DEFAULT;
|
||||
}
|
||||
|
||||
public function getWatchedParent(): ?WatchedParentDetails
|
||||
{
|
||||
$watchMap = $this->getWatchMap();
|
||||
unset($watchMap[$this->entity->getMorphClass()]);
|
||||
|
||||
if (isset($watchMap['chapter'])) {
|
||||
return new WatchedParentDetails('chapter', $watchMap['chapter']);
|
||||
}
|
||||
|
||||
if (isset($watchMap['book'])) {
|
||||
return new WatchedParentDetails('book', $watchMap['book']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function updateLevelByName(string $level): void
|
||||
{
|
||||
$levelValue = WatchLevels::levelNameToValue($level);
|
||||
$this->updateLevelByValue($levelValue);
|
||||
}
|
||||
|
||||
public function updateLevelByValue(int $level): void
|
||||
{
|
||||
if ($level < 0) {
|
||||
$this->remove();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->updateLevel($level);
|
||||
}
|
||||
|
||||
public function getWatchMap(): array
|
||||
{
|
||||
if (!is_null($this->watchMap)) {
|
||||
return $this->watchMap;
|
||||
}
|
||||
|
||||
$entities = [$this->entity];
|
||||
if ($this->entity instanceof BookChild) {
|
||||
$entities[] = $this->entity->book;
|
||||
}
|
||||
if ($this->entity instanceof Page && $this->entity->chapter) {
|
||||
$entities[] = $this->entity->chapter;
|
||||
}
|
||||
|
||||
$query = Watch::query()
|
||||
->where('user_id', '=', $this->user->id)
|
||||
->where(function (Builder $subQuery) use ($entities) {
|
||||
foreach ($entities as $entity) {
|
||||
$subQuery->orWhere(function (Builder $whereQuery) use ($entity) {
|
||||
$whereQuery->where('watchable_type', '=', $entity->getMorphClass())
|
||||
->where('watchable_id', '=', $entity->id);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$this->watchMap = $query->get(['watchable_type', 'level'])
|
||||
->pluck('level', 'watchable_type')
|
||||
->toArray();
|
||||
|
||||
return $this->watchMap;
|
||||
}
|
||||
|
||||
protected function getWatchLevelValue()
|
||||
{
|
||||
return $this->getWatchMap()[$this->entity->getMorphClass()] ?? WatchLevels::DEFAULT;
|
||||
}
|
||||
|
||||
protected function updateLevel(int $levelValue): void
|
||||
{
|
||||
Watch::query()->updateOrCreate([
|
||||
'watchable_id' => $this->entity->id,
|
||||
'watchable_type' => $this->entity->getMorphClass(),
|
||||
'user_id' => $this->user->id,
|
||||
], [
|
||||
'level' => $levelValue,
|
||||
]);
|
||||
$this->watchMap = null;
|
||||
}
|
||||
|
||||
protected function remove(): void
|
||||
{
|
||||
$this->entityQuery()->delete();
|
||||
$this->watchMap = null;
|
||||
}
|
||||
|
||||
protected function entityQuery(): Builder
|
||||
{
|
||||
return Watch::query()->where('watchable_id', '=', $this->entity->id)
|
||||
->where('watchable_type', '=', $this->entity->getMorphClass())
|
||||
->where('user_id', '=', $this->user->id);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* @return 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.
|
||||
* @return 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';
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\App\AppVersion;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Http\Controllers\Api\ApiController;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -17,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
|
||||
@@ -26,18 +25,15 @@ class ApiDocsGenerator
|
||||
*/
|
||||
public static function generateConsideringCache(): Collection
|
||||
{
|
||||
$appVersion = AppVersion::get();
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -83,19 +79,11 @@ class ApiDocsGenerator
|
||||
protected function loadDetailsFromControllers(Collection $routes): Collection
|
||||
{
|
||||
return $routes->map(function (array $route) {
|
||||
$class = $this->getReflectionClass($route['controller']);
|
||||
$method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
|
||||
$comment = $method->getDocComment();
|
||||
$route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null;
|
||||
$route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null;
|
||||
$route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
|
||||
|
||||
// Load class description for the model
|
||||
// Not ideal to have it here on each route, but adding it in a more structured manner would break
|
||||
// docs resulting JSON format and therefore be an API break.
|
||||
// Save refactoring for a more significant set of changes.
|
||||
$classComment = $class->getDocComment();
|
||||
$route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null;
|
||||
|
||||
return $route;
|
||||
});
|
||||
}
|
||||
@@ -148,13 +136,12 @@ class ApiDocsGenerator
|
||||
/**
|
||||
* Parse out the description text from a class method comment.
|
||||
*/
|
||||
protected function parseDescriptionFromDocBlockComment(string $comment): string
|
||||
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] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,16 +150,6 @@ class ApiDocsGenerator
|
||||
* @throws ReflectionException
|
||||
*/
|
||||
protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
|
||||
{
|
||||
return $this->getReflectionClass($className)->getMethod($methodName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reflection class from the given class name.
|
||||
*
|
||||
* @throws ReflectionException
|
||||
*/
|
||||
protected function getReflectionClass(string $className): ReflectionClass
|
||||
{
|
||||
$class = $this->reflectionClasses[$className] ?? null;
|
||||
if ($class === null) {
|
||||
@@ -180,7 +157,7 @@ class ApiDocsGenerator
|
||||
$this->reflectionClasses[$className] = $class;
|
||||
}
|
||||
|
||||
return $class;
|
||||
return $class->getMethod($methodName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
|
||||
class ApiEntityListFormatter
|
||||
{
|
||||
@@ -12,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.
|
||||
@@ -21,17 +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)
|
||||
@@ -72,28 +62,6 @@ class ApiEntityListFormatter
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Include parent book/chapter info in the formatted data.
|
||||
*/
|
||||
public function withParents(): self
|
||||
{
|
||||
$this->withField('book', function (Entity $entity) {
|
||||
if ($entity instanceof BookChild && $entity->book) {
|
||||
return $entity->book->only(['id', 'name', 'slug']);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
$this->withField('chapter', function (Entity $entity) {
|
||||
if ($entity instanceof Page && $entity->chapter) {
|
||||
return $entity->chapter->only(['id', 'name', 'slug']);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the data and return an array of formatted content.
|
||||
* @return array[]
|
||||
|
||||
@@ -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, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Exceptions\ApiAuthException;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Auth\GuardHelpers;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\Guard;
|
||||
@@ -147,7 +146,7 @@ class ApiTokenGuard implements Guard
|
||||
throw new ApiAuthException(trans('errors.api_user_token_expired'), 403);
|
||||
}
|
||||
|
||||
if (!$token->user->can(Permission::AccessApi)) {
|
||||
if (!$token->user->can('access-api')) {
|
||||
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\App;
|
||||
|
||||
class AppVersion
|
||||
{
|
||||
protected static string $version = '';
|
||||
|
||||
/**
|
||||
* Get the application's version number from its top-level `version` text file.
|
||||
*/
|
||||
public static function get(): string
|
||||
{
|
||||
if (!empty(static::$version)) {
|
||||
return static::$version;
|
||||
}
|
||||
|
||||
$versionFile = base_path('version');
|
||||
$version = trim(file_get_contents($versionFile));
|
||||
static::$version = $version;
|
||||
|
||||
return $version;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +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')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view for /opensearch.xml.
|
||||
*/
|
||||
public function opensearch()
|
||||
{
|
||||
return response()
|
||||
->view('misc.opensearch')
|
||||
->header('Content-Type', 'application/opensearchdescription+xml');
|
||||
}
|
||||
}
|
||||
@@ -1,53 +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, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the registration of Laravel's default email verification system
|
||||
*/
|
||||
protected function configureEmailVerification(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\App;
|
||||
|
||||
/**
|
||||
* Assigned to models that can have slugs.
|
||||
* Must have the below properties.
|
||||
*
|
||||
* @property string $slug
|
||||
*/
|
||||
interface SluggableInterface
|
||||
{
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\App;
|
||||
|
||||
use BookStack\Http\ApiController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class SystemApiController extends ApiController
|
||||
{
|
||||
/**
|
||||
* Read details regarding the BookStack instance.
|
||||
* Some details may be null where not set, like the app logo for example.
|
||||
*/
|
||||
public function read(): JsonResponse
|
||||
{
|
||||
$logoSetting = setting('app-logo', '');
|
||||
if ($logoSetting === 'none') {
|
||||
$logo = null;
|
||||
} else {
|
||||
$logo = $logoSetting ? url($logoSetting) : url('/logo.png');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'version' => AppVersion::get(),
|
||||
'instance_id' => setting('instance-id'),
|
||||
'app_name' => setting('app-name'),
|
||||
'app_logo' => $logo,
|
||||
'base_url' => url('/'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
use BookStack\App\AppVersion;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Settings\SettingService;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
/**
|
||||
* Get the path to a versioned file.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
function versioned_asset(string $file = ''): string
|
||||
{
|
||||
$version = AppVersion::get();
|
||||
|
||||
$additional = '';
|
||||
if (config('app.env') === 'development') {
|
||||
$additional = sha1_file(public_path($file));
|
||||
}
|
||||
|
||||
$path = $file . '?version=' . urlencode($version) . $additional;
|
||||
|
||||
return url($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get the current User.
|
||||
* Defaults to public 'Guest' user if not logged in.
|
||||
*/
|
||||
function user(): User
|
||||
{
|
||||
return auth()->user() ?: User::getGuest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has a permission. If an ownable element
|
||||
* is passed in the jointPermissions are checked against that particular item.
|
||||
*/
|
||||
function userCan(string|Permission $permission, ?Model $ownable = null): bool
|
||||
{
|
||||
if (is_null($ownable)) {
|
||||
return user()->can($permission);
|
||||
}
|
||||
|
||||
// Check permission on ownable item
|
||||
$permissions = app()->make(PermissionApplicator::class);
|
||||
|
||||
return $permissions->checkOwnableUserAccess($ownable, $permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user can perform the given action on any items in the system.
|
||||
* Can be provided the class name of an entity to filter ability to that specific entity type.
|
||||
*/
|
||||
function userCanOnAny(string|Permission $action, string $entityClass = ''): bool
|
||||
{
|
||||
$permissions = app()->make(PermissionApplicator::class);
|
||||
|
||||
return $permissions->checkUserHasEntityPermissionOnAny($action, $entityClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to access system settings.
|
||||
*
|
||||
* @return mixed|SettingService
|
||||
*/
|
||||
function setting(?string $key = null, mixed $default = null): mixed
|
||||
{
|
||||
$settingService = app()->make(SettingService::class);
|
||||
|
||||
if (is_null($key)) {
|
||||
return $settingService;
|
||||
}
|
||||
|
||||
return $settingService->get($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a path to a theme resource.
|
||||
* Returns null if a theme is not configured and
|
||||
* therefore a full path is not available for use.
|
||||
*/
|
||||
function theme_path(string $path = ''): ?string
|
||||
{
|
||||
$theme = Theme::getTheme();
|
||||
if (!$theme) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\App;
|
||||
namespace BookStack;
|
||||
|
||||
class Application extends \Illuminate\Foundation\Application
|
||||
{
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
106
app/Auth/Access/ExternalBaseUserProvider.php
Normal file
106
app/Auth/Access/ExternalBaseUserProvider.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\UserProvider;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ExternalBaseUserProvider implements UserProvider
|
||||
{
|
||||
/**
|
||||
* The user model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model;
|
||||
|
||||
/**
|
||||
* LdapUserProvider constructor.
|
||||
*/
|
||||
public function __construct(string $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance of the model.
|
||||
*
|
||||
* @return Model
|
||||
*/
|
||||
public function createModel()
|
||||
{
|
||||
$class = '\\' . ltrim($this->model, '\\');
|
||||
|
||||
return new $class();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user by their unique identifier.
|
||||
*
|
||||
* @param mixed $identifier
|
||||
*
|
||||
* @return Authenticatable|null
|
||||
*/
|
||||
public function retrieveById($identifier)
|
||||
{
|
||||
return $this->createModel()->newQuery()->find($identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user by their unique identifier and "remember me" token.
|
||||
*
|
||||
* @param mixed $identifier
|
||||
* @param string $token
|
||||
*
|
||||
* @return Authenticatable|null
|
||||
*/
|
||||
public function retrieveByToken($identifier, $token)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the "remember me" token for the given user in storage.
|
||||
*
|
||||
* @param Authenticatable $user
|
||||
* @param string $token
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updateRememberToken(Authenticatable $user, $token)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user by the given credentials.
|
||||
*
|
||||
* @param array $credentials
|
||||
*
|
||||
* @return Authenticatable|null
|
||||
*/
|
||||
public function retrieveByCredentials(array $credentials)
|
||||
{
|
||||
// Search current user base by looking up a uid
|
||||
$model = $this->createModel();
|
||||
|
||||
return $model->newQuery()
|
||||
->where('external_auth_id', $credentials['external_auth_id'])
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user against the given credentials.
|
||||
*
|
||||
* @param Authenticatable $user
|
||||
* @param array $credentials
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function validateCredentials(Authenticatable $user, array $credentials)
|
||||
{
|
||||
// Should be done in the guard.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
39
app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php
Normal file
39
app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Guards;
|
||||
|
||||
/**
|
||||
* Saml2 Session Guard.
|
||||
*
|
||||
* The saml2 login process is async in nature meaning it does not fit very well
|
||||
* into the default laravel 'Guard' auth flow. Instead most of the logic is done
|
||||
* via the Saml2 controller & Saml2Service. This class provides a safer, thin
|
||||
* version of SessionGuard.
|
||||
*/
|
||||
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
|
||||
{
|
||||
/**
|
||||
* Validate a user's credentials.
|
||||
*
|
||||
* @param array $credentials
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function validate(array $credentials = [])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate a user using the given credentials.
|
||||
*
|
||||
* @param array $credentials
|
||||
* @param bool $remember
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function attempt(array $credentials = [], $remember = false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user