mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-05-04 18:08:46 +03:00
Compare commits
134 Commits
v25.12.1
...
l10n_devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d3cd6f7ee | ||
|
|
3ddfa9b948 | ||
|
|
55317039ac | ||
|
|
24e6087ef8 | ||
|
|
7c1d30bc8f | ||
|
|
c1610c4532 | ||
|
|
2e2f59fa0f | ||
|
|
cc6e9e0546 | ||
|
|
0f59981932 | ||
|
|
a37f903dc7 | ||
|
|
74aa897626 | ||
|
|
4b624596c8 | ||
|
|
00239bb6c8 | ||
|
|
241563e8fc | ||
|
|
e91747785b | ||
|
|
4f370ccddb | ||
|
|
743a21a02f | ||
|
|
0c9fabb6de | ||
|
|
426f9ac493 | ||
|
|
ec0b0384a2 | ||
|
|
e7e019d3d4 | ||
|
|
1339f668eb | ||
|
|
befa3a8fbb | ||
|
|
083fb1a600 | ||
|
|
a2bb5bdf10 | ||
|
|
e274a5fa4e | ||
|
|
18364d1e6e | ||
|
|
0760e677b2 | ||
|
|
208629ee1f | ||
|
|
346dc27979 | ||
|
|
1c1ad1d1b7 | ||
|
|
f14fc68b66 | ||
|
|
93f84a81b2 | ||
|
|
4feb50e7ee | ||
|
|
c7e2b487c1 | ||
|
|
4e3fa4822f | ||
|
|
684a94c419 | ||
|
|
c3c8577f05 | ||
|
|
5fbaab4740 | ||
|
|
3d9d5fef51 | ||
|
|
5e78dc6ed5 | ||
|
|
c33853ed84 | ||
|
|
e033578fea | ||
|
|
a7dd998ac9 | ||
|
|
b9d650785a | ||
|
|
abed4eae0c | ||
|
|
c7d3775bb9 | ||
|
|
0b659671fe | ||
|
|
25790fd024 | ||
|
|
1763ac550b | ||
|
|
fd6867e577 | ||
|
|
5ebc1fe3b0 | ||
|
|
a44756168d | ||
|
|
fa1dc162bd | ||
|
|
5763d26b17 | ||
|
|
04dd9f8e19 | ||
|
|
0120b475eb | ||
|
|
8a59895ba0 | ||
|
|
a9ffd3e0c7 | ||
|
|
4f18fea086 | ||
|
|
362859ac23 | ||
|
|
7cbfd72920 | ||
|
|
49df47836e | ||
|
|
f4c9d2b049 | ||
|
|
60a3b0c0ac | ||
|
|
5f5fea7c83 | ||
|
|
6e7cc169d1 | ||
|
|
6216c89f82 | ||
|
|
404e67afbc | ||
|
|
6d64262a61 | ||
|
|
151823b84e | ||
|
|
27240be499 | ||
|
|
d0d1bb9829 | ||
|
|
7d0237c798 | ||
|
|
f2f76a3c56 | ||
|
|
ec3dd856db | ||
|
|
25ed242f61 | ||
|
|
10c46534e0 | ||
|
|
dd42b9b43f | ||
|
|
9a12e3a8b7 | ||
|
|
7aef0a48b3 | ||
|
|
6808292c90 | ||
|
|
c10b0fd5b9 | ||
|
|
1077a4efd0 | ||
|
|
23f3f35f6b | ||
|
|
229a99ba24 | ||
|
|
8e99fc6783 | ||
|
|
80204518a2 | ||
|
|
a8d96fd389 | ||
|
|
9d15c79fee | ||
|
|
e1de1f0583 | ||
|
|
a2017ffa55 | ||
|
|
9646339933 | ||
|
|
e4383765e1 | ||
|
|
5d547fcf4c | ||
|
|
826b36c985 | ||
|
|
3fa1174e7a | ||
|
|
50e8501027 | ||
|
|
8a221f64e4 | ||
|
|
035be66ebc | ||
|
|
227027fc45 | ||
|
|
0f040fe8b1 | ||
|
|
10ebe53bd9 | ||
|
|
5e12b678c7 | ||
|
|
057d7be0bc | ||
|
|
984a73159f | ||
|
|
a20438b901 | ||
|
|
9d3d0a4a07 | ||
|
|
5038d124e1 | ||
|
|
f7890c2dd9 | ||
|
|
45ae03ceac | ||
|
|
aa0a8dda11 | ||
|
|
120ee38383 | ||
|
|
cd84074cdf | ||
|
|
4949520194 | ||
|
|
46dcc30bf7 | ||
|
|
9f7d3b55dd | ||
|
|
3e5e88dc87 | ||
|
|
c77a0fdff3 | ||
|
|
6a63b38bb3 | ||
|
|
1b17bb3929 | ||
|
|
9fcfc762ec | ||
|
|
c32b1686a9 | ||
|
|
36649a6188 | ||
|
|
ff59bbdc07 | ||
|
|
4dc443b7df | ||
|
|
19f02d927e | ||
|
|
da7bedd2e4 | ||
|
|
20db372596 | ||
|
|
43eed1660c | ||
|
|
e6b754fad0 | ||
|
|
018de5def3 | ||
|
|
5c4fc3dc2c | ||
|
|
570ded10fa |
@@ -351,10 +351,25 @@ EXPORT_PDF_COMMAND_TIMEOUT=15
|
||||
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
||||
WKHTMLTOPDF=false
|
||||
|
||||
# Allow <script> tags in page content
|
||||
# Allow JavaScript, and other potentiall dangerous content in page content.
|
||||
# This also removes CSP-level JavaScript control.
|
||||
# Note, if set to 'true' the page editor may still escape scripts.
|
||||
# DEPRECATED: Use 'APP_CONTENT_FILTERING' instead as detailed below. Activiting this option
|
||||
# effectively sets APP_CONTENT_FILTERING='' (No filtering)
|
||||
ALLOW_CONTENT_SCRIPTS=false
|
||||
|
||||
# Control the behaviour of content filtering, primarily used for page content.
|
||||
# This setting is a string of characters which represent different available filters:
|
||||
# - j - Filter out JavaScript and unknown binary data based content
|
||||
# - h - Filter out unexpected, and potentially dangerous, HTML elements
|
||||
# - f - Filter out unexpected form elements
|
||||
# - a - Run content through a more complex allowlist filter
|
||||
# This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
||||
# Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
||||
# Note: The default value will always be the most-strict, so it's advised to leave this unset in your own configuration
|
||||
# to ensure you are always using the full range of filters.
|
||||
APP_CONTENT_FILTERING="jfha"
|
||||
|
||||
# Indicate if robots/crawlers should crawl your instance.
|
||||
# Can be 'true', 'false' or 'null'.
|
||||
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting.
|
||||
|
||||
2
.forgejo/CODE_OF_CONDUCT.md
Normal file
2
.forgejo/CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,2 @@
|
||||
Please find our community rules on our website here:
|
||||
https://www.bookstackapp.com/about/community-rules/
|
||||
4
.forgejo/FUNDING.yml
Normal file
4
.forgejo/FUNDING.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [ssddanbrown]
|
||||
ko_fi: ssddanbrown
|
||||
13
.forgejo/ISSUE_TEMPLATE/config.yml
Normal file
13
.forgejo/ISSUE_TEMPLATE/config.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Community Forum Support
|
||||
url: https://community.bookstackapp.com
|
||||
about: Get support by talking with the BookStack team & community.
|
||||
|
||||
- name: Debugging & Common Issues
|
||||
url: https://www.bookstackapp.com/docs/admin/debugging/
|
||||
about: Find details on how to debug issues and view common issues with their resolutions.
|
||||
|
||||
- name: Official Support Plans
|
||||
url: https://www.bookstackapp.com/support/
|
||||
about: View our official support plans that offer assured support for business.
|
||||
@@ -33,7 +33,7 @@ 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://codeberg.org/bookstack/bookstack/issues) for any existing issues that cover the fundamental benefit/goal of your request.
|
||||
options:
|
||||
- label: I have searched for existing issues and none cover my fundamental request
|
||||
required: true
|
||||
@@ -56,3 +56,13 @@ body:
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: ai-thoughts
|
||||
attributes:
|
||||
label: Have you used generative AI/LLMs to create any thoughts in this request?
|
||||
description: |
|
||||
We ask that no machine generated thoughts or ideas are provided, to avoid us spending time considering the ideas
|
||||
of a machine instead of a human. Further guidance on this can be found [in the BookStack community rules](https://www.bookstackapp.com/about/community-rules/#use-of-llmsai).
|
||||
options:
|
||||
- label: This request only contains the thoughts & ideas of a human
|
||||
required: true
|
||||
@@ -15,11 +15,11 @@ body:
|
||||
- type: checkboxes
|
||||
id: searchissue
|
||||
attributes:
|
||||
label: Searched GitHub Issues
|
||||
label: Searched Existing Issues
|
||||
description: |
|
||||
I have searched for the issue and potential resolutions within the [project's GitHub issue list](https://github.com/BookStackApp/BookStack/issues)
|
||||
I have searched for the issue and potential resolutions within the [project's issue list](https://codeberg.org/bookstack/bookstack/issues)
|
||||
options:
|
||||
- label: I have searched GitHub for the issue.
|
||||
- label: I have searched for the issue.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: scenario
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the [latest version](https://github.com/BookStackApp/BookStack/releases) of BookStack is supported.
|
||||
Only the [latest version](https://codeberg.org/bookstack/bookstack/releases) of BookStack is supported.
|
||||
We generally don't support older versions of BookStack due to maintenance effort and
|
||||
since we aim to provide a fairly stable upgrade path for new versions.
|
||||
|
||||
@@ -12,16 +12,14 @@ If you'd like to be notified of new potential security concerns you can [sign-up
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you've found an issue that likely has no impact to existing users (For example, in a development-only branch)
|
||||
feel free to raise it via a standard GitHub bug report issue.
|
||||
If you've found an issue that likely has no impact to existing users (For example, an issue only in the development branch)
|
||||
feel free to raise it via a standard Codeberg bug report issue.
|
||||
|
||||
If the issue could have a security impact to BookStack instances,
|
||||
please directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown).
|
||||
You will need to log in to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
|
||||
Alternatively you can send a DM via Mastodon to [@danb@fosstodon.org](https://fosstodon.org/@danb).
|
||||
please directly contact the lead maintainer via email Dan Brown using the [details found here](https://www.bookstackapp.com/links/contact/).
|
||||
|
||||
Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability
|
||||
can often take a little time due to the amount of preparation required, to ensure the vulnerability has
|
||||
been covered, and to create the content required to adequately notify the user-base.
|
||||
|
||||
Thank you for keeping BookStack instances safe!
|
||||
Thank you for keeping BookStack instances safe!
|
||||
11
.forgejo/pull_request_template.md
Normal file
11
.forgejo/pull_request_template.md
Normal file
@@ -0,0 +1,11 @@
|
||||
## Details
|
||||
|
||||
<!-- Write details of your pull request in here -->
|
||||
<!-- Include references to any relevant issues/discussions -->
|
||||
|
||||
## Checklist
|
||||
|
||||
<!-- Put an 'x' in between the brackets below to confirm these elements -->
|
||||
|
||||
- [ ] I have read the [BookStack community rules](https://www.bookstackapp.com/about/community-rules/).
|
||||
- [ ] This PR does not feature significant use of LLM/AI generation as per the community rules above.
|
||||
@@ -1,6 +1,7 @@
|
||||
name: analyse-php
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
@@ -11,14 +12,16 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker.io/library/node:24-trixie
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://code.forgejo.org/actions/checkout@v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: https://github.com/shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.3
|
||||
php-version: 8.5
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
@@ -27,14 +30,16 @@ jobs:
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v4
|
||||
uses: https://code.forgejo.org/actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-8.3
|
||||
key: ${{ runner.os }}-composer-8.5
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --ansi
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_TOKEN }}"}}'
|
||||
|
||||
- name: Run static analysis check
|
||||
run: composer check-static
|
||||
@@ -1,6 +1,7 @@
|
||||
name: lint-js
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- '**.js'
|
||||
@@ -13,9 +14,11 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker.io/library/node:24-trixie
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://code.forgejo.org/actions/checkout@v6
|
||||
|
||||
- name: Install NPM deps
|
||||
run: npm ci
|
||||
@@ -1,6 +1,7 @@
|
||||
name: lint-php
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
@@ -11,14 +12,16 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker.io/library/node:24-trixie
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://code.forgejo.org/actions/checkout@v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: https://github.com/shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.3
|
||||
php-version: 8.5
|
||||
tools: phpcs
|
||||
|
||||
- name: Run formatting check
|
||||
33
.forgejo/workflows/sync-translations.yml
Normal file
33
.forgejo/workflows/sync-translations.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Crowdin Action
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ development ]
|
||||
paths:
|
||||
- 'lang/**.php'
|
||||
schedule:
|
||||
- cron: '30 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker.io/library/node:24-trixie
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://code.forgejo.org/actions/checkout@v6
|
||||
|
||||
- name: crowdin action
|
||||
uses: https://github.com/crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
localization_branch_name: l10n_development
|
||||
create_pull_request: false
|
||||
github_base_url: codeberg.org
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
@@ -1,6 +1,7 @@
|
||||
name: test-js
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- '**.js'
|
||||
@@ -15,9 +16,11 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker.io/library/node:24-trixie
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://code.forgejo.org/actions/checkout@v6
|
||||
|
||||
- name: Install NPM deps
|
||||
run: npm ci
|
||||
@@ -1,6 +1,7 @@
|
||||
name: test-migrations
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
@@ -13,15 +14,25 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker.io/library/node:24-trixie
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.2', '8.3', '8.4', '8.5']
|
||||
services:
|
||||
mysql:
|
||||
image: docker.io/library/mariadb:12.2.2-noble
|
||||
env:
|
||||
MARIADB_USER: bookstack-test
|
||||
MARIADB_PASSWORD: bookstack-test
|
||||
MARIADB_DATABASE: bookstack-test
|
||||
MARIADB_ROOT_PASSWORD: password
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://code.forgejo.org/actions/checkout@v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: https://github.com/shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||
@@ -32,34 +43,31 @@ jobs:
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v4
|
||||
uses: https://code.forgejo.org/actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Start MySQL
|
||||
run: |
|
||||
sudo systemctl start mysql
|
||||
|
||||
- name: Create database & user
|
||||
run: |
|
||||
mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
|
||||
mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';"
|
||||
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
|
||||
mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --ansi
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_TOKEN }}"}}'
|
||||
|
||||
- name: Start migration test
|
||||
env:
|
||||
TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test'
|
||||
run: |
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
||||
|
||||
- name: Start migration:rollback test
|
||||
env:
|
||||
TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test'
|
||||
run: |
|
||||
php${{ matrix.php }} artisan migrate:rollback --force -n --database=mysql_testing
|
||||
|
||||
- name: Start migration rerun test
|
||||
env:
|
||||
TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test'
|
||||
run: |
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
||||
@@ -1,6 +1,7 @@
|
||||
name: test-php
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
@@ -13,15 +14,25 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker.io/library/node:24-trixie
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.2', '8.3', '8.4', '8.5']
|
||||
services:
|
||||
mysql:
|
||||
image: docker.io/library/mariadb:12.2.2-noble
|
||||
env:
|
||||
MARIADB_USER: bookstack-test
|
||||
MARIADB_PASSWORD: bookstack-test
|
||||
MARIADB_DATABASE: bookstack-test
|
||||
MARIADB_ROOT_PASSWORD: password
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://code.forgejo.org/actions/checkout@v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: https://github.com/shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp
|
||||
@@ -32,30 +43,25 @@ jobs:
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v4
|
||||
uses: https://code.forgejo.org/actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Start Database
|
||||
run: |
|
||||
sudo systemctl start mysql
|
||||
|
||||
- name: Setup Database
|
||||
run: |
|
||||
mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
|
||||
mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';"
|
||||
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
|
||||
mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --ansi
|
||||
env:
|
||||
COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_TOKEN }}"}}'
|
||||
|
||||
- name: Migrate and seed the database
|
||||
env:
|
||||
TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test'
|
||||
run: |
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
||||
php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
|
||||
|
||||
- name: Run PHP tests
|
||||
env:
|
||||
TEST_DATABASE_URL: 'mysql://bookstack-test:bookstack-test@mysql/bookstack-test'
|
||||
run: php${{ matrix.php }} ./vendor/bin/phpunit
|
||||
86
.github/CODE_OF_CONDUCT.md
vendored
86
.github/CODE_OF_CONDUCT.md
vendored
@@ -1,84 +1,2 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, gender identity and expression, level of experience,
|
||||
education, socio-economic status, nationality, personal appearance, race,
|
||||
religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
### Project Maintainer Standards
|
||||
|
||||
Project maintainers should generally follow these additional standards:
|
||||
|
||||
* Avoid using a negative or harsh tone in communication, Even if the other party
|
||||
is being negative themselves.
|
||||
* When providing criticism, try to make it constructive to lead the other person
|
||||
down the correct path.
|
||||
* Keep the [project definition](https://github.com/BookStackApp/BookStack#project-definition)
|
||||
in mind when deciding what's in scope of the Project.
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior. In addition, Project
|
||||
maintainers are responsible for following the standards themselves.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at the email address shown on [the profile here](https://github.com/ssddanbrown). All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
Please find our community rules on our website here:
|
||||
https://www.bookstackapp.com/about/community-rules/
|
||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord Chat Support
|
||||
url: https://discord.gg/ztkBqR2
|
||||
about: Realtime support & chat with the BookStack community and the team.
|
||||
- name: Open Issues Here Instead
|
||||
url: https://codeberg.org/bookstack/bookstack/issues
|
||||
about: This project has migrated to Codeberg, please open issues there instead.
|
||||
|
||||
- name: Debugging & Common Issues
|
||||
url: https://www.bookstackapp.com/docs/admin/debugging/
|
||||
|
||||
10
.github/pull_request_template.md
vendored
Normal file
10
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
**Warning:**
|
||||
|
||||
This project has migrated to Codeberg:
|
||||
https://codeberg.org/bookstack/bookstack
|
||||
|
||||
Please open pull requests here instead.
|
||||
|
||||
ANY PULL REQUESTS OPENED HERE WILL BE CLOSED WITHOUT COMMENT OR MERGE.
|
||||
|
||||
---
|
||||
18
.github/translators.txt
vendored
18
.github/translators.txt
vendored
@@ -444,7 +444,7 @@ Irjan Olsen (Irch) :: Norwegian Bokmal
|
||||
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
|
||||
Red (RedVortex) :: Hebrew
|
||||
xgrug :: Chinese Simplified
|
||||
HrCalmar :: Danish
|
||||
Calle Calmar (HrCalmar) :: Danish
|
||||
Avishay Rapp (AvishayRapp) :: Hebrew
|
||||
matthias4217 :: French
|
||||
Berke BOYLU2 (berkeboylu2) :: Turkish
|
||||
@@ -521,3 +521,19 @@ setiawan setiawan (culture.setiawan) :: Indonesian
|
||||
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
|
||||
Gabriel Silver (GabrielBSilver) :: Hebrew
|
||||
Tomas Darius Davainis (Tomasdd) :: Lithuanian
|
||||
CriedHero :: Chinese Simplified
|
||||
Henrik (henrik2105) :: Norwegian Bokmal
|
||||
FoW (fofwisdom) :: Korean
|
||||
serinf-lauza :: French
|
||||
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
|
||||
Shadluk Avan (quldosh) :: Uzbek
|
||||
Marci (MartonPoto) :: Hungarian
|
||||
Michał Sadurski (wheeskeey) :: Polish
|
||||
JanDziaslo :: Polish
|
||||
Charllys Fernandes (CharllysFernandes) :: Portuguese, Brazilian
|
||||
Ilgiz Zigangirov (inov8) :: Russian
|
||||
Max Israelsson (Blezie) :: Swedish
|
||||
Skiddybison5924 (chris-devel0per) :: German
|
||||
Veyilla Nightwhisper (Veyilla) :: German
|
||||
João Barbosa (hypeedd) :: Portuguese
|
||||
Abcdefg Hijklmn (collatek) :: Korean
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
/node_modules
|
||||
/.vscode
|
||||
/composer
|
||||
/composer.phar
|
||||
/coverage
|
||||
Homestead.yaml
|
||||
.env
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-2025, Dan Brown and the BookStack project contributors.
|
||||
Copyright (c) 2015-2026, Dan Brown and the BookStack project contributors.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -45,11 +45,11 @@ class ForgotPasswordController extends Controller
|
||||
);
|
||||
|
||||
if ($response === Password::RESET_LINK_SENT) {
|
||||
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email'));
|
||||
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->input('email'));
|
||||
}
|
||||
|
||||
if (in_array($response, [Password::RESET_LINK_SENT, Password::INVALID_USER, Password::RESET_THROTTLED])) {
|
||||
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
|
||||
$message = trans('auth.reset_password_sent', ['email' => $request->input('email')]);
|
||||
$this->showSuccessNotification($message);
|
||||
|
||||
return redirect('/password/email')->with('status', trans($response));
|
||||
|
||||
@@ -32,12 +32,12 @@ class LoginController extends Controller
|
||||
{
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
$authMethod = config('auth.method');
|
||||
$preventInitiation = $request->get('prevent_auto_init') === 'true';
|
||||
$preventInitiation = $request->input('prevent_auto_init') === 'true';
|
||||
|
||||
if ($request->has('email')) {
|
||||
session()->flashInput([
|
||||
'email' => $request->get('email'),
|
||||
'password' => (config('app.env') === 'demo') ? $request->get('password', '') : '',
|
||||
'email' => $request->input('email'),
|
||||
'password' => (config('app.env') === 'demo') ? $request->input('password', '') : '',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ class LoginController extends Controller
|
||||
public function login(Request $request)
|
||||
{
|
||||
$this->validateLogin($request);
|
||||
$username = $request->get($this->username());
|
||||
$username = $request->input($this->username());
|
||||
|
||||
// Check login throttling attempts to see if they've gone over the limit
|
||||
if ($this->hasTooManyLoginAttempts($request)) {
|
||||
|
||||
@@ -84,7 +84,7 @@ class MfaBackupCodesController extends Controller
|
||||
],
|
||||
]);
|
||||
|
||||
$updatedCodes = $codeService->removeInputCodeFromSet($request->get('code'), $codes);
|
||||
$updatedCodes = $codeService->removeInputCodeFromSet($request->input('code'), $codes);
|
||||
MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, $updatedCodes);
|
||||
|
||||
$mfaSession->markVerifiedForUser($user);
|
||||
|
||||
@@ -51,14 +51,14 @@ class MfaController extends Controller
|
||||
*/
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$desiredMethod = $request->get('method');
|
||||
$desiredMethod = $request->input('method');
|
||||
$userMethods = $this->currentOrLastAttemptedUser()
|
||||
->mfaValues()
|
||||
->get(['id', 'method'])
|
||||
->groupBy('method');
|
||||
|
||||
// Basic search for the default option for a user.
|
||||
// (Prioritises totp over backup codes)
|
||||
// (Prioritises TOTP over backup codes)
|
||||
$method = $userMethods->has($desiredMethod) ? $desiredMethod : $userMethods->keys()->sort()->reverse()->first();
|
||||
$otherMethods = $userMethods->keys()->filter(function ($userMethod) use ($method) {
|
||||
return $method !== $userMethod;
|
||||
|
||||
@@ -48,8 +48,7 @@ class RegisterController extends Controller
|
||||
public function postRegister(Request $request)
|
||||
{
|
||||
$this->registrationService->ensureRegistrationAllowed();
|
||||
$this->validator($request->all())->validate();
|
||||
$userData = $request->all();
|
||||
$userData = $this->validator($request->all())->validate();
|
||||
|
||||
try {
|
||||
$user = $this->registrationService->registerUser($userData);
|
||||
|
||||
@@ -48,7 +48,7 @@ class ResetPasswordController extends Controller
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
// database. Otherwise, we will parse the error and return the response.
|
||||
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
|
||||
$response = Password::broker()->reset($credentials, function (User $user, string $password) {
|
||||
$user->password = Hash::make($password);
|
||||
@@ -63,7 +63,7 @@ class ResetPasswordController extends Controller
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $response === Password::PASSWORD_RESET
|
||||
? $this->sendResetResponse()
|
||||
: $this->sendResetFailedResponse($request, $response, $request->get('token'));
|
||||
: $this->sendResetFailedResponse($request, $response, $request->input('token'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,7 +78,7 @@ class Saml2Controller extends Controller
|
||||
*/
|
||||
public function startAcs(Request $request)
|
||||
{
|
||||
$samlResponse = $request->get('SAMLResponse', null);
|
||||
$samlResponse = $request->input('SAMLResponse', null);
|
||||
|
||||
if (empty($samlResponse)) {
|
||||
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
|
||||
@@ -100,7 +100,7 @@ class Saml2Controller extends Controller
|
||||
*/
|
||||
public function processAcs(Request $request)
|
||||
{
|
||||
$acsId = $request->get('id', null);
|
||||
$acsId = $request->input('id', null);
|
||||
$cacheKey = 'saml2_acs:' . $acsId;
|
||||
$samlResponse = null;
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class SocialController extends Controller
|
||||
if ($request->has('error') && $request->has('error_description')) {
|
||||
throw new SocialSignInException(trans('errors.social_login_bad_response', [
|
||||
'socialAccount' => $socialDriver,
|
||||
'error' => $request->get('error_description'),
|
||||
'error' => $request->input('error_description'),
|
||||
]), '/login');
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class UserInviteController extends Controller
|
||||
}
|
||||
|
||||
$user = $this->userRepo->getById($userId);
|
||||
$user->password = Hash::make($request->get('password'));
|
||||
$user->password = Hash::make($request->input('password'));
|
||||
$user->email_confirmed = true;
|
||||
$user->save();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Access;
|
||||
use BookStack\Access\Notifications\ConfirmEmailNotification;
|
||||
use BookStack\Exceptions\ConfirmationEmailException;
|
||||
use BookStack\Users\Models\User;
|
||||
use Exception;
|
||||
|
||||
class EmailConfirmationService extends UserTokenService
|
||||
{
|
||||
@@ -16,6 +17,7 @@ class EmailConfirmationService extends UserTokenService
|
||||
* Also removes any existing old ones.
|
||||
*
|
||||
* @throws ConfirmationEmailException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function sendConfirmation(User $user): void
|
||||
{
|
||||
|
||||
@@ -71,7 +71,7 @@ class LoginService
|
||||
}
|
||||
|
||||
$lastLoginDetails = $this->getLastLoginAttemptDetails();
|
||||
$this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false);
|
||||
$this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,17 +48,16 @@ class MfaValue extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Easily get the decrypted MFA value for the given user and method.
|
||||
* Get the decrypted MFA value for the given user and method.
|
||||
*/
|
||||
public static function getValueForUser(User $user, string $method): ?string
|
||||
{
|
||||
/** @var MfaValue $mfaVal */
|
||||
$mfaVal = static::query()
|
||||
->where('user_id', '=', $user->id)
|
||||
->where('method', '=', $method)
|
||||
->first();
|
||||
|
||||
return $mfaVal ? $mfaVal->getValue() : null;
|
||||
return $mfaVal?->getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,10 +9,7 @@ use phpseclib3\Math\BigInteger;
|
||||
|
||||
class OidcJwtSigningKey
|
||||
{
|
||||
/**
|
||||
* @var PublicKey
|
||||
*/
|
||||
protected $key;
|
||||
protected PublicKey $key;
|
||||
|
||||
/**
|
||||
* Can be created either from a JWK parameter array or local file path to load a certificate from.
|
||||
@@ -20,15 +17,13 @@ class OidcJwtSigningKey
|
||||
* 'file:///var/www/cert.pem'
|
||||
* ['kty' => 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'].
|
||||
*
|
||||
* @param array|string $jwkOrKeyPath
|
||||
*
|
||||
* @throws OidcInvalidKeyException
|
||||
*/
|
||||
public function __construct($jwkOrKeyPath)
|
||||
public function __construct(array|string $jwkOrKeyPath)
|
||||
{
|
||||
if (is_array($jwkOrKeyPath)) {
|
||||
$this->loadFromJwkArray($jwkOrKeyPath);
|
||||
} elseif (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) {
|
||||
} elseif (str_starts_with($jwkOrKeyPath, 'file://')) {
|
||||
$this->loadFromPath($jwkOrKeyPath);
|
||||
} else {
|
||||
throw new OidcInvalidKeyException('Unexpected type of key value provided');
|
||||
@@ -38,7 +33,7 @@ class OidcJwtSigningKey
|
||||
/**
|
||||
* @throws OidcInvalidKeyException
|
||||
*/
|
||||
protected function loadFromPath(string $path)
|
||||
protected function loadFromPath(string $path): void
|
||||
{
|
||||
try {
|
||||
$key = PublicKeyLoader::load(
|
||||
@@ -58,7 +53,7 @@ class OidcJwtSigningKey
|
||||
/**
|
||||
* @throws OidcInvalidKeyException
|
||||
*/
|
||||
protected function loadFromJwkArray(array $jwk)
|
||||
protected function loadFromJwkArray(array $jwk): void
|
||||
{
|
||||
// 'alg' is optional for a JWK, but we will still attempt to validate if
|
||||
// it exists otherwise presume it will be compatible.
|
||||
@@ -82,7 +77,7 @@ class OidcJwtSigningKey
|
||||
throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected');
|
||||
}
|
||||
|
||||
$n = strtr($jwk['n'] ?? '', '-_', '+/');
|
||||
$n = strtr($jwk['n'], '-_', '+/');
|
||||
|
||||
try {
|
||||
$key = PublicKeyLoader::load([
|
||||
|
||||
@@ -102,12 +102,12 @@ class OidcJwtWithClaims implements ProvidesClaims
|
||||
protected function validateTokenStructure(): void
|
||||
{
|
||||
foreach (['header', 'payload'] as $prop) {
|
||||
if (empty($this->$prop) || !is_array($this->$prop)) {
|
||||
if (empty($this->$prop)) {
|
||||
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($this->signature) || !is_string($this->signature)) {
|
||||
if (empty($this->signature)) {
|
||||
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@ class OidcService
|
||||
$url = $provider->getAuthorizationUrl();
|
||||
session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
|
||||
|
||||
$returnUrl = Theme::dispatch(ThemeEvents::OIDC_AUTH_PRE_REDIRECT, $url);
|
||||
if (is_string($returnUrl)) {
|
||||
$url = $returnUrl;
|
||||
}
|
||||
|
||||
return [
|
||||
'url' => $url,
|
||||
'state' => $provider->getState(),
|
||||
|
||||
@@ -39,7 +39,7 @@ class OidcUserDetails
|
||||
): void {
|
||||
$this->externalId = $claims->getClaim($idClaim) ?? $this->externalId;
|
||||
$this->email = $claims->getClaim('email') ?? $this->email;
|
||||
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
|
||||
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?: $this->name;
|
||||
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
|
||||
$this->picture = static::getPicture($claims) ?: $this->picture;
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ class RegistrationService
|
||||
// Email restriction
|
||||
$this->ensureEmailDomainAllowed($userEmail);
|
||||
|
||||
// Ensure user does not already exist
|
||||
// Ensure the user does not already exist
|
||||
$alreadyUser = !is_null($this->userRepo->getByEmail($userEmail));
|
||||
if ($alreadyUser) {
|
||||
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
|
||||
@@ -99,7 +99,7 @@ class RegistrationService
|
||||
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
|
||||
$newUser->attachDefaultRole();
|
||||
|
||||
// Assign social account if given
|
||||
// Assign a social account if given
|
||||
if ($socialAccount) {
|
||||
$newUser->socialAccounts()->save($socialAccount);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ class RegistrationService
|
||||
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
|
||||
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $authSystem, $newUser);
|
||||
|
||||
// Start email confirmation flow if required
|
||||
// Start the email confirmation flow if required
|
||||
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
|
||||
$newUser->save();
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ class Saml2Service
|
||||
/**
|
||||
* Extract the details of a user from a SAML response.
|
||||
*
|
||||
* @return array{external_id: string, name: string, email: string, saml_id: string}
|
||||
* @return array{external_id: string, name: string, email: string|null, saml_id: string}
|
||||
*/
|
||||
protected function getUserDetails(string $samlID, $samlAttributes): array
|
||||
{
|
||||
@@ -357,7 +357,7 @@ class Saml2Service
|
||||
]);
|
||||
}
|
||||
|
||||
if ($userDetails['email'] === null) {
|
||||
if (empty($userDetails['email'])) {
|
||||
throw new SamlException(trans('errors.saml_no_email_address'));
|
||||
}
|
||||
|
||||
|
||||
@@ -117,14 +117,14 @@ class SocialAuthService
|
||||
}
|
||||
|
||||
// When a user is logged in and the social account exists and is already linked to the current user.
|
||||
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
|
||||
if ($isLoggedIn && $socialAccount->user->id === $currentUser->id) {
|
||||
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver]));
|
||||
|
||||
return redirect('/my-account/auth#social_accounts');
|
||||
}
|
||||
|
||||
// When a user is logged in, A social account exists but the users do not match.
|
||||
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
|
||||
if ($isLoggedIn && $socialAccount->user->id != $currentUser->id) {
|
||||
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver]));
|
||||
|
||||
return redirect('/my-account/auth#social_accounts');
|
||||
|
||||
@@ -17,19 +17,19 @@ class AuditLogController extends Controller
|
||||
$this->checkPermission(Permission::SettingsManage);
|
||||
$this->checkPermission(Permission::UsersManage);
|
||||
|
||||
$sort = $request->get('sort', 'activity_date');
|
||||
$order = $request->get('order', 'desc');
|
||||
$sort = $request->input('sort', 'activity_date');
|
||||
$order = $request->input('order', 'desc');
|
||||
$listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
|
||||
'created_at' => trans('settings.audit_table_date'),
|
||||
'type' => trans('settings.audit_table_event'),
|
||||
]);
|
||||
|
||||
$filters = [
|
||||
'event' => $request->get('event', ''),
|
||||
'date_from' => $request->get('date_from', ''),
|
||||
'date_to' => $request->get('date_to', ''),
|
||||
'user' => $request->get('user', ''),
|
||||
'ip' => $request->get('ip', ''),
|
||||
'event' => $request->input('event', ''),
|
||||
'date_from' => $request->input('date_from', ''),
|
||||
'date_to' => $request->input('date_to', ''),
|
||||
'user' => $request->input('user', ''),
|
||||
'ip' => $request->input('ip', ''),
|
||||
];
|
||||
|
||||
$query = Activity::query()
|
||||
|
||||
@@ -20,7 +20,7 @@ class FavouriteController extends Controller
|
||||
public function index(Request $request, QueryTopFavourites $topFavourites)
|
||||
{
|
||||
$viewCount = 20;
|
||||
$page = intval($request->get('page', 1));
|
||||
$page = intval($request->input('page', 1));
|
||||
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
|
||||
|
||||
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
|
||||
|
||||
68
app/Activity/Controllers/TagApiController.php
Normal file
68
app/Activity/Controllers/TagApiController.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\TagRepo;
|
||||
use BookStack\Http\ApiController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Endpoints to query data about tags in the system.
|
||||
* You'll only see results based on tags applied to content you have access to.
|
||||
* There are no general create/update/delete endpoints here since tags do not exist
|
||||
* by themselves, they are managed via the items they are assigned to.
|
||||
*/
|
||||
class TagApiController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
protected TagRepo $tagRepo,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'listValues' => [
|
||||
'name' => ['required', 'string'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of tag names used in the system.
|
||||
* Only the name field can be used in filters.
|
||||
*/
|
||||
public function listNames(): JsonResponse
|
||||
{
|
||||
$tagQuery = $this->tagRepo
|
||||
->queryWithTotalsForApi('');
|
||||
|
||||
return $this->apiListingResponse($tagQuery, [
|
||||
'name', 'values', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count',
|
||||
], [], [
|
||||
'name'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of tag values, which have been set for the given tag name,
|
||||
* which must be provided as a query parameter on the request.
|
||||
* Only the value field can be used in filters.
|
||||
*/
|
||||
public function listValues(Request $request): JsonResponse
|
||||
{
|
||||
$data = $this->validate($request, $this->rules()['listValues']);
|
||||
$name = $data['name'];
|
||||
|
||||
$tagQuery = $this->tagRepo->queryWithTotalsForApi($name);
|
||||
|
||||
return $this->apiListingResponse($tagQuery, [
|
||||
'name', 'value', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count',
|
||||
], [], [
|
||||
'value',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,9 @@ class TagController extends Controller
|
||||
'usages' => trans('entities.tags_usages'),
|
||||
]);
|
||||
|
||||
$nameFilter = $request->get('name', '');
|
||||
$nameFilter = $request->input('name', '');
|
||||
$tags = $this->tagRepo
|
||||
->queryWithTotals($listOptions, $nameFilter)
|
||||
->queryWithTotalsForList($listOptions, $nameFilter)
|
||||
->paginate(50)
|
||||
->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [
|
||||
'name' => $nameFilter,
|
||||
@@ -46,7 +46,7 @@ class TagController extends Controller
|
||||
*/
|
||||
public function getNameSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search', '');
|
||||
$searchTerm = $request->input('search', '');
|
||||
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
||||
|
||||
return response()->json($suggestions);
|
||||
@@ -57,8 +57,8 @@ class TagController extends Controller
|
||||
*/
|
||||
public function getValueSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search', '');
|
||||
$tagName = $request->get('name', '');
|
||||
$searchTerm = $request->input('search', '');
|
||||
$tagName = $request->input('name', '');
|
||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
||||
|
||||
return response()->json($suggestions);
|
||||
|
||||
@@ -8,6 +8,8 @@ use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use BookStack\Users\Models\OwnableInterface;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use BookStack\Util\HtmlToPlainText;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -82,7 +84,14 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
|
||||
public function safeHtml(): string
|
||||
{
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
|
||||
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
|
||||
return $filter->filterString($this->html ?? '');
|
||||
}
|
||||
|
||||
public function getPlainText(): string
|
||||
{
|
||||
$converter = new HtmlToPlainText();
|
||||
return $converter->convert($this->html ?? '');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
|
||||
@@ -24,7 +24,7 @@ class CommentCreationNotification extends BaseActivityNotification
|
||||
$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),
|
||||
$locale->trans('notifications.detail_comment') => $comment->getPlainText(),
|
||||
]);
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
|
||||
@@ -24,7 +24,7 @@ class CommentMentionNotification extends BaseActivityNotification
|
||||
$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),
|
||||
$locale->trans('notifications.detail_comment') => $comment->getPlainText(),
|
||||
]);
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
|
||||
@@ -15,14 +15,14 @@ use BookStack\Users\Models\User;
|
||||
class NotificationManager
|
||||
{
|
||||
/**
|
||||
* @var class-string<NotificationHandler>[]
|
||||
* @var array<string, class-string<NotificationHandler>[]>
|
||||
*/
|
||||
protected array $handlers = [];
|
||||
protected array $handlersByActivity = [];
|
||||
|
||||
public function handle(Activity $activity, string|Loggable $detail, User $user): void
|
||||
{
|
||||
$activityType = $activity->type;
|
||||
$handlersToRun = $this->handlers[$activityType] ?? [];
|
||||
$handlersToRun = $this->handlersByActivity[$activityType] ?? [];
|
||||
foreach ($handlersToRun as $handlerClass) {
|
||||
/** @var NotificationHandler $handler */
|
||||
$handler = new $handlerClass();
|
||||
@@ -35,12 +35,12 @@ class NotificationManager
|
||||
*/
|
||||
public function registerHandler(string $activityType, string $handlerClass): void
|
||||
{
|
||||
if (!isset($this->handlers[$activityType])) {
|
||||
$this->handlers[$activityType] = [];
|
||||
if (!isset($this->handlersByActivity[$activityType])) {
|
||||
$this->handlersByActivity[$activityType] = [];
|
||||
}
|
||||
|
||||
if (!in_array($handlerClass, $this->handlers[$activityType])) {
|
||||
$this->handlers[$activityType][] = $handlerClass;
|
||||
if (!in_array($handlerClass, $this->handlersByActivity[$activityType])) {
|
||||
$this->handlersByActivity[$activityType][] = $handlerClass;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@ class TagRepo
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query against all tags in the system.
|
||||
* Start a query against all tags in the system, with total counts for their usage,
|
||||
* suitable for a system interface list with listing options.
|
||||
*/
|
||||
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
|
||||
public function queryWithTotalsForList(SimpleListOptions $listOptions, string $nameFilter): Builder
|
||||
{
|
||||
$searchTerm = $listOptions->getSearch();
|
||||
$sort = $listOptions->getSort();
|
||||
@@ -28,17 +29,34 @@ class TagRepo
|
||||
$sort = 'value';
|
||||
}
|
||||
|
||||
$query = $this->baseQueryWithTotals($nameFilter, $searchTerm)
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query against all tags in the system, with total counts for their usage,
|
||||
* which can be used via the API.
|
||||
*/
|
||||
public function queryWithTotalsForApi(string $nameFilter): Builder
|
||||
{
|
||||
$query = $this->baseQueryWithTotals($nameFilter, '');
|
||||
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
}
|
||||
|
||||
protected function baseQueryWithTotals(string $nameFilter, string $searchTerm): Builder
|
||||
{
|
||||
$query = Tag::query()
|
||||
->select([
|
||||
'name',
|
||||
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
|
||||
DB::raw('COUNT(id) as usages'),
|
||||
DB::raw('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('CAST(SUM(IF(entity_type = \'page\', 1, 0)) as UNSIGNED) as page_count'),
|
||||
DB::raw('CAST(SUM(IF(entity_type = \'chapter\', 1, 0)) as UNSIGNED) as chapter_count'),
|
||||
DB::raw('CAST(SUM(IF(entity_type = \'book\', 1, 0)) as UNSIGNED) as book_count'),
|
||||
DB::raw('CAST(SUM(IF(entity_type = \'bookshelf\', 1, 0)) as UNSIGNED) as shelf_count'),
|
||||
])
|
||||
->orderBy($sort, $listOptions->getOrder())
|
||||
->whereHas('entity');
|
||||
|
||||
if ($nameFilter) {
|
||||
@@ -57,7 +75,7 @@ class TagRepo
|
||||
});
|
||||
}
|
||||
|
||||
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,14 @@ use ReflectionMethod;
|
||||
|
||||
class ApiDocsGenerator
|
||||
{
|
||||
/**
|
||||
* @var array<string, ReflectionClass>
|
||||
*/
|
||||
protected array $reflectionClasses = [];
|
||||
|
||||
/**
|
||||
* @var array<string, ApiController>
|
||||
*/
|
||||
protected array $controllerClasses = [];
|
||||
|
||||
/**
|
||||
@@ -107,7 +114,6 @@ class ApiDocsGenerator
|
||||
*/
|
||||
protected function getBodyParamsFromClass(string $className, string $methodName): ?array
|
||||
{
|
||||
/** @var ApiController $class */
|
||||
$class = $this->controllerClasses[$className] ?? null;
|
||||
if ($class === null) {
|
||||
$class = app()->make($className);
|
||||
@@ -153,7 +159,7 @@ class ApiDocsGenerator
|
||||
$matches = [];
|
||||
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
|
||||
|
||||
$text = implode(' ', $matches[1] ?? []);
|
||||
$text = implode(' ', $matches[1]);
|
||||
return str_replace(' ', "\n", $text);
|
||||
}
|
||||
|
||||
@@ -189,11 +195,12 @@ class ApiDocsGenerator
|
||||
protected function getFlatApiRoutes(): Collection
|
||||
{
|
||||
return collect(Route::getRoutes()->getRoutes())->filter(function ($route) {
|
||||
return strpos($route->uri, 'api/') === 0;
|
||||
return str_starts_with($route->uri, 'api/');
|
||||
})->map(function ($route) {
|
||||
[$controller, $controllerMethod] = explode('@', $route->action['uses']);
|
||||
$baseModelName = explode('.', explode('/', $route->uri)[1])[0];
|
||||
$shortName = $baseModelName . '-' . $controllerMethod;
|
||||
$controllerMethodKebab = Str::kebab($controllerMethod);
|
||||
$shortName = $baseModelName . '-' . $controllerMethodKebab;
|
||||
|
||||
return [
|
||||
'name' => $shortName,
|
||||
@@ -201,7 +208,7 @@ class ApiDocsGenerator
|
||||
'method' => $route->methods[0],
|
||||
'controller' => $controller,
|
||||
'controller_method' => $controllerMethod,
|
||||
'controller_method_kebab' => Str::kebab($controllerMethod),
|
||||
'controller_method_kebab' => $controllerMethodKebab,
|
||||
'base_model' => $baseModelName,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -74,18 +74,21 @@ class ApiEntityListFormatter
|
||||
|
||||
/**
|
||||
* Include parent book/chapter info in the formatted data.
|
||||
* These functions are careful to not load the relation themselves, since they should
|
||||
* have already been loaded in a more efficient manner, with permissions applied, by the time
|
||||
* the parent fields are handled here.
|
||||
*/
|
||||
public function withParents(): self
|
||||
{
|
||||
$this->withField('book', function (Entity $entity) {
|
||||
if ($entity instanceof BookChild && $entity->book) {
|
||||
if ($entity instanceof BookChild && $entity->relationLoaded('book') && $entity->getRelationValue('book')) {
|
||||
return $entity->book->only(['id', 'name', 'slug']);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
$this->withField('chapter', function (Entity $entity) {
|
||||
if ($entity instanceof Page && $entity->chapter) {
|
||||
if ($entity instanceof Page && $entity->relationLoaded('chapter') && $entity->getRelationValue('chapter')) {
|
||||
return $entity->chapter->only(['id', 'name', 'slug']);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -16,30 +16,15 @@ class ApiTokenGuard implements Guard
|
||||
{
|
||||
use GuardHelpers;
|
||||
|
||||
/**
|
||||
* The request instance.
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* @var LoginService
|
||||
*/
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* The last auth exception thrown in this request.
|
||||
*
|
||||
* @var ApiAuthException
|
||||
*/
|
||||
protected $lastAuthException;
|
||||
protected ApiAuthException|null $lastAuthException = null;
|
||||
|
||||
/**
|
||||
* ApiTokenGuard constructor.
|
||||
*/
|
||||
public function __construct(Request $request, LoginService $loginService)
|
||||
{
|
||||
$this->request = $request;
|
||||
$this->loginService = $loginService;
|
||||
public function __construct(
|
||||
protected Request $request,
|
||||
protected LoginService $loginService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +52,7 @@ class ApiTokenGuard implements Guard
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if current user is authenticated. If not, throw an exception.
|
||||
* Determine if the current user is authenticated. If not, throw an exception.
|
||||
*
|
||||
* @throws ApiAuthException
|
||||
*
|
||||
@@ -121,7 +106,7 @@ class ApiTokenGuard implements Guard
|
||||
throw new ApiAuthException(trans('errors.api_no_authorization_found'));
|
||||
}
|
||||
|
||||
if (strpos($authToken, ':') === false || strpos($authToken, 'Token ') !== 0) {
|
||||
if (!str_contains($authToken, ':') || !str_starts_with($authToken, 'Token ')) {
|
||||
throw new ApiAuthException(trans('errors.api_bad_authorization_format'));
|
||||
}
|
||||
}
|
||||
@@ -155,7 +140,7 @@ class ApiTokenGuard implements Guard
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function validate(array $credentials = [])
|
||||
public function validate(array $credentials = []): bool
|
||||
{
|
||||
if (empty($credentials['id']) || empty($credentials['secret'])) {
|
||||
return false;
|
||||
@@ -175,7 +160,7 @@ class ApiTokenGuard implements Guard
|
||||
/**
|
||||
* "Log out" the currently authenticated user.
|
||||
*/
|
||||
public function logout()
|
||||
public function logout(): void
|
||||
{
|
||||
$this->user = null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ class ListingResponseBuilder
|
||||
*/
|
||||
protected array $fields;
|
||||
|
||||
/**
|
||||
* Which fields are filterable.
|
||||
* When null, the $fields above are used instead (Allow all fields).
|
||||
* @var string[]|null
|
||||
*/
|
||||
protected array|null $filterableFields = null;
|
||||
|
||||
/**
|
||||
* @var array<callable>
|
||||
*/
|
||||
@@ -54,7 +61,7 @@ class ListingResponseBuilder
|
||||
{
|
||||
$filteredQuery = $this->filterQuery($this->query);
|
||||
|
||||
$total = $filteredQuery->count();
|
||||
$total = $filteredQuery->getCountForPagination();
|
||||
$data = $this->fetchData($filteredQuery)->each(function ($model) {
|
||||
foreach ($this->resultModifiers as $modifier) {
|
||||
$modifier($model);
|
||||
@@ -77,6 +84,14 @@ class ListingResponseBuilder
|
||||
$this->resultModifiers[] = $modifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit filtering to just the given set of fields.
|
||||
*/
|
||||
public function setFilterableFields(array $fields): void
|
||||
{
|
||||
$this->filterableFields = $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the data to return within the response.
|
||||
*/
|
||||
@@ -94,7 +109,7 @@ class ListingResponseBuilder
|
||||
protected function filterQuery(Builder $query): Builder
|
||||
{
|
||||
$query = clone $query;
|
||||
$requestFilters = $this->request->get('filter', []);
|
||||
$requestFilters = $this->request->input('filter', []);
|
||||
if (!is_array($requestFilters)) {
|
||||
return $query;
|
||||
}
|
||||
@@ -114,10 +129,11 @@ class ListingResponseBuilder
|
||||
protected function requestFilterToQueryFilter($fieldKey, $value): ?array
|
||||
{
|
||||
$splitKey = explode(':', $fieldKey);
|
||||
$field = $splitKey[0];
|
||||
$field = strtolower($splitKey[0]);
|
||||
$filterOperator = $splitKey[1] ?? 'eq';
|
||||
|
||||
if (!in_array($field, $this->fields)) {
|
||||
$filterFields = $this->filterableFields ?? $this->fields;
|
||||
if (!in_array($field, $filterFields)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -140,8 +156,8 @@ class ListingResponseBuilder
|
||||
$defaultSortName = $this->fields[0];
|
||||
$direction = 'asc';
|
||||
|
||||
$sort = $this->request->get('sort', '');
|
||||
if (strpos($sort, '-') === 0) {
|
||||
$sort = $this->request->input('sort', '');
|
||||
if (str_starts_with($sort, '-')) {
|
||||
$direction = 'desc';
|
||||
}
|
||||
|
||||
@@ -160,9 +176,9 @@ class ListingResponseBuilder
|
||||
protected function countAndOffsetQuery(Builder $query): Builder
|
||||
{
|
||||
$query = clone $query;
|
||||
$offset = max(0, $this->request->get('offset', 0));
|
||||
$offset = max(0, $this->request->input('offset', 0));
|
||||
$maxCount = config('api.max_item_count');
|
||||
$count = $this->request->get('count', config('api.default_item_count'));
|
||||
$count = $this->request->input('count', config('api.default_item_count'));
|
||||
$count = max(min($maxCount, $count), 1);
|
||||
|
||||
return $query->skip($offset)->take($count);
|
||||
|
||||
@@ -48,11 +48,11 @@ class UserApiTokenController extends Controller
|
||||
$secret = Str::random(32);
|
||||
|
||||
$token = (new ApiToken())->forceFill([
|
||||
'name' => $request->get('name'),
|
||||
'name' => $request->input('name'),
|
||||
'token_id' => Str::random(32),
|
||||
'secret' => Hash::make($secret),
|
||||
'user_id' => $user->id,
|
||||
'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
|
||||
'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(),
|
||||
]);
|
||||
|
||||
while (ApiToken::query()->where('token_id', '=', $token->token_id)->exists()) {
|
||||
@@ -100,8 +100,8 @@ class UserApiTokenController extends Controller
|
||||
|
||||
[$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
|
||||
$token->fill([
|
||||
'name' => $request->get('name'),
|
||||
'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
|
||||
'name' => $request->input('name'),
|
||||
'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(),
|
||||
])->save();
|
||||
|
||||
$this->logActivity(ActivityType::API_TOKEN_UPDATE, $token);
|
||||
|
||||
@@ -65,6 +65,13 @@ class AppServiceProvider extends ServiceProvider
|
||||
URL::forceScheme($isHttps ? 'https' : 'http');
|
||||
}
|
||||
|
||||
// Set SMTP mail driver to use a local domain matching the app domain,
|
||||
// which helps avoid defaulting to a 127.0.0.1 domain
|
||||
if ($appUrl) {
|
||||
$hostName = parse_url($appUrl, PHP_URL_HOST) ?: null;
|
||||
config()->set('mail.mailers.smtp.local_domain', $hostName);
|
||||
}
|
||||
|
||||
// Allow longer string lengths after upgrade to utf8mb4
|
||||
Schema::defaultStringLength(191);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace BookStack\App\Providers;
|
||||
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Theming\ThemeService;
|
||||
use BookStack\Theming\ThemeViews;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ThemeServiceProvider extends ServiceProvider
|
||||
@@ -24,7 +26,26 @@ class ThemeServiceProvider extends ServiceProvider
|
||||
{
|
||||
// Boot up the theme system
|
||||
$themeService = $this->app->make(ThemeService::class);
|
||||
$viewFactory = $this->app->make('view');
|
||||
$themeViews = new ThemeViews($viewFactory->getFinder());
|
||||
|
||||
// Use a custom include so that we can insert theme views before/after includes.
|
||||
// This is done, even if no theme is active, so that view caching does not create problems
|
||||
// when switching between themes or when switching a theme on/off.
|
||||
$viewFactory->share('__themeViews', $themeViews);
|
||||
Blade::directive('include', function ($expression) {
|
||||
return "<?php echo \$__themeViews->handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>";
|
||||
});
|
||||
|
||||
if (!$themeService->getTheme()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$themeService->loadModules();
|
||||
$themeService->readThemeActions();
|
||||
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
|
||||
|
||||
$themeViews->registerViewPathsForTheme($themeService->getModules());
|
||||
$themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ class PwaManifestBuilder
|
||||
// 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
|
||||
// Context: https://codeberg.org/bookstack/bookstack/issues/4649
|
||||
$darkMode = (bool) setting()->getForCurrentUser('dark-mode-enabled');
|
||||
$appName = setting('app-name');
|
||||
|
||||
|
||||
@@ -81,8 +81,7 @@ function setting(?string $key = null, mixed $default = null): mixed
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns null if a theme is not configured, and therefore a full path is not available for use.
|
||||
*/
|
||||
function theme_path(string $path = ''): ?string
|
||||
{
|
||||
|
||||
@@ -37,10 +37,15 @@ return [
|
||||
// The limit for all uploaded files, including images and attachments in MB.
|
||||
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
|
||||
|
||||
// Allow <script> tags to entered within page content.
|
||||
// <script> tags are escaped by default.
|
||||
// Even when overridden the WYSIWYG editor may still escape script content.
|
||||
'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
|
||||
// Control the behaviour of content filtering, primarily used for page content.
|
||||
// This setting is a string of characters which represent different available filters:
|
||||
// - j - Filter out JavaScript and unknown binary data based content
|
||||
// - h - Filter out unexpected, and potentially dangerous, HTML elements
|
||||
// - f - Filter out unexpected form elements
|
||||
// - a - Run content through a more complex allowlist filter
|
||||
// This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
||||
// Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
||||
'content_filtering' => env('APP_CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jhfa'),
|
||||
|
||||
// Allow server-side fetches to be performed to potentially unknown
|
||||
// and user-provided locations. Primarily used in exports when loading
|
||||
@@ -48,8 +53,8 @@ return [
|
||||
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
|
||||
|
||||
// Override the default behaviour for allowing crawlers to crawl the instance.
|
||||
// May be ignored if view has be overridden or modified.
|
||||
// Defaults to null since, if not set, 'app-public' status used instead.
|
||||
// May be ignored if the underlying view has been overridden or modified.
|
||||
// Defaults to null in which case the 'app-public' status is used instead.
|
||||
'allow_robots' => env('ALLOW_ROBOTS', null),
|
||||
|
||||
// Application Base URL, Used by laravel in development commands
|
||||
|
||||
@@ -68,7 +68,7 @@ return [
|
||||
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
|
||||
* Symbol, ZapfDingbats.
|
||||
*/
|
||||
'font_dir' => storage_path('fonts/'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
|
||||
'font_dir' => storage_path('fonts/dompdf'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
|
||||
|
||||
/**
|
||||
* The location of the DOMPDF font cache directory.
|
||||
@@ -78,7 +78,7 @@ return [
|
||||
*
|
||||
* Note: This directory must exist and be writable by the webserver process.
|
||||
*/
|
||||
'font_cache' => storage_path('fonts/'),
|
||||
'font_cache' => storage_path('fonts/dompdf/cache'),
|
||||
|
||||
/**
|
||||
* The location of a temporary directory.
|
||||
|
||||
@@ -8,12 +8,6 @@
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
|
||||
// Join up possible view locations
|
||||
$viewPaths = [realpath(base_path('resources/views'))];
|
||||
if ($theme = env('APP_THEME', false)) {
|
||||
array_unshift($viewPaths, base_path('themes/' . $theme));
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
// App theme
|
||||
@@ -26,7 +20,7 @@ return [
|
||||
// Most templating systems load templates from disk. Here you may specify
|
||||
// an array of paths that should be checked for your views. Of course
|
||||
// the usual Laravel view path has already been registered for you.
|
||||
'paths' => $viewPaths,
|
||||
'paths' => [realpath(base_path('resources/views'))],
|
||||
|
||||
// Compiled View Path
|
||||
// This option determines where all the compiled Blade templates will be
|
||||
|
||||
@@ -32,7 +32,7 @@ class AssignSortRuleCommand extends Command
|
||||
*/
|
||||
public function handle(BookSorter $sorter): int
|
||||
{
|
||||
$sortRuleId = intval($this->argument('sort-rule')) ?? 0;
|
||||
$sortRuleId = intval($this->argument('sort-rule'));
|
||||
if ($sortRuleId === 0) {
|
||||
return $this->listSortRules();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ class CopyShelfPermissionsCommand extends Command
|
||||
{
|
||||
$shelfSlug = $this->option('slug');
|
||||
$cascadeAll = $this->option('all');
|
||||
$noInteraction = boolval($this->option('no-interaction'));
|
||||
$shelves = null;
|
||||
|
||||
if (!$cascadeAll && !$shelfSlug) {
|
||||
@@ -41,14 +42,16 @@ class CopyShelfPermissionsCommand extends Command
|
||||
}
|
||||
|
||||
if ($cascadeAll) {
|
||||
$continue = $this->confirm(
|
||||
'Permission settings for all shelves will be cascaded. ' .
|
||||
'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. ' .
|
||||
'Are you sure you want to proceed?'
|
||||
);
|
||||
if (!$noInteraction) {
|
||||
$continue = $this->confirm(
|
||||
'Permission settings for all shelves will be cascaded. ' .
|
||||
'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. ' .
|
||||
'Are you sure you want to proceed?',
|
||||
);
|
||||
|
||||
if (!$continue && !$this->hasOption('no-interaction')) {
|
||||
return 0;
|
||||
if (!$continue) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
$shelves = $queries->start()->get(['id']);
|
||||
|
||||
320
app/Console/Commands/InstallModuleCommand.php
Normal file
320
app/Console/Commands/InstallModuleCommand.php
Normal file
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Theming\ThemeModule;
|
||||
use BookStack\Theming\ThemeModuleException;
|
||||
use BookStack\Theming\ThemeModuleManager;
|
||||
use BookStack\Theming\ThemeModuleZip;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class InstallModuleCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:install-module
|
||||
{location : The URL or path of the module file}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Install a module to the currently configured theme';
|
||||
|
||||
protected array $cleanupActions = [];
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$location = $this->argument('location');
|
||||
|
||||
// Get the ZIP file containing the module files
|
||||
$zipPath = $this->getPathToZip($location);
|
||||
if (!$zipPath) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Validate module zip file (metadata, size, etc...) and get module instance
|
||||
$zip = new ThemeModuleZip($zipPath);
|
||||
$themeModule = $this->validateAndGetModuleInfoFromZip($zip);
|
||||
if (!$themeModule) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get the theme folder in use, attempting to create one if no active theme in use
|
||||
$themeFolder = $this->getThemeFolder();
|
||||
if (!$themeFolder) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get the modules folder of the theme, attempting to create it if not existing,
|
||||
// and create a new module manager instance.
|
||||
$moduleFolder = $this->getModuleFolder($themeFolder);
|
||||
if (!$moduleFolder) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
$manager = new ThemeModuleManager($moduleFolder);
|
||||
|
||||
// Handle existing modules with the same name
|
||||
$exitingModulesWithName = $manager->getByName($themeModule->name);
|
||||
$shouldContinue = $this->handleExistingModulesWithSameName($exitingModulesWithName, $manager);
|
||||
if (!$shouldContinue) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Extract module ZIP into the theme modules folder
|
||||
try {
|
||||
$newModule = $manager->addFromZip($themeModule->name, $zip);
|
||||
} catch (ThemeModuleException $exception) {
|
||||
$this->error("ERROR: Failed to install module with error: {$exception->getMessage()}");
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Module \"{$newModule->name}\" ({$newModule->getVersion()}) successfully installed!");
|
||||
$this->info("Install location: {$moduleFolder}/{$newModule->folderName}");
|
||||
$this->cleanup();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ThemeModule[] $existingModules
|
||||
*/
|
||||
protected function handleExistingModulesWithSameName(array $existingModules, ThemeModuleManager $manager): bool
|
||||
{
|
||||
if (count($existingModules) === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->warn("The following modules already exist with the same name:");
|
||||
foreach ($existingModules as $folder => $module) {
|
||||
$this->line("{$module->name} ({$folder}:{$module->getVersion()}) - {$module->description}");
|
||||
}
|
||||
$this->line('');
|
||||
|
||||
$choices = ['Cancel module install', 'Add alongside existing module'];
|
||||
if (count($existingModules) === 1) {
|
||||
$choices[] = 'Replace existing module';
|
||||
}
|
||||
$choice = $this->choice("What would you like to do?", $choices, 0, null, false);
|
||||
if ($choice === 'Cancel module install') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($choice === 'Replace existing module') {
|
||||
$existingModuleFolder = array_key_first($existingModules);
|
||||
$this->info("Replacing existing module in {$existingModuleFolder} folder");
|
||||
$manager->deleteModuleFolder($existingModuleFolder);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getModuleFolder(string $themeFolder): string|null
|
||||
{
|
||||
$path = $themeFolder . DIRECTORY_SEPARATOR . 'modules';
|
||||
|
||||
if (file_exists($path) && !is_dir($path)) {
|
||||
$this->error("ERROR: Cannot create a modules folder, file already exists at {$path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!file_exists($path)) {
|
||||
$created = mkdir($path, 0755, true);
|
||||
if (!$created) {
|
||||
$this->error("ERROR: Failed to create a modules folder at {$path}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
protected function getThemeFolder(): string|null
|
||||
{
|
||||
$path = theme_path('');
|
||||
if (!$path || !is_dir($path)) {
|
||||
$shouldCreate = $this->confirm('No active theme folder found, would you like to create one?');
|
||||
if (!$shouldCreate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$folder = 'custom';
|
||||
while (file_exists(base_path("themes" . DIRECTORY_SEPARATOR . $folder))) {
|
||||
$folder = 'custom-' . Str::random(4);
|
||||
}
|
||||
|
||||
$path = base_path("themes/{$folder}");
|
||||
$created = mkdir($path, 0755, true);
|
||||
if (!$created) {
|
||||
$this->error('Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme');
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->info("Created theme folder at {$path}");
|
||||
$this->warn("You will need to set APP_THEME={$folder} in your BookStack env configuration to enable this theme!");
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeModule|null
|
||||
{
|
||||
if (!$zip->exists()) {
|
||||
$this->error("ERROR: Cannot open ZIP file at {$zip->getPath()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($zip->getContentsSize() > (50 * 1024 * 1024)) {
|
||||
$this->error("ERROR: Module ZIP file contents are too large. Maximum size is 50MB");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$themeModule = $zip->getModuleInstance();
|
||||
} catch (ThemeModuleException $exception) {
|
||||
$this->error("ERROR: Failed to read module metadata with error: {$exception->getMessage()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return $themeModule;
|
||||
}
|
||||
|
||||
protected function downloadModuleFile(string $location): string|null
|
||||
{
|
||||
$httpRequests = app()->make(HttpRequestService::class);
|
||||
$client = $httpRequests->buildClient(30, ['stream' => true]);
|
||||
$originalUrl = parse_url($location);
|
||||
$currentLocation = $location;
|
||||
$maxRedirects = 3;
|
||||
$redirectCount = 0;
|
||||
|
||||
// Follow redirects up to 3 times for the same hostname
|
||||
do {
|
||||
$resp = $client->sendRequest(new Request('GET', $currentLocation));
|
||||
$statusCode = $resp->getStatusCode();
|
||||
|
||||
if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) {
|
||||
$redirectLocation = $resp->getHeaderLine('Location');
|
||||
if ($redirectLocation) {
|
||||
$redirectUrl = parse_url($redirectLocation);
|
||||
$redirectOriginMatches = ($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '')
|
||||
&& ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '')
|
||||
&& ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '');
|
||||
|
||||
if (!$redirectOriginMatches) {
|
||||
$redirectOrigin = ($redirectUrl['scheme'] ?? '') . '://' . ($redirectUrl['host'] ?? '') . (isset($redirectUrl['port']) ? ':' . $redirectUrl['port'] : '');
|
||||
$this->info("The download URL is redirecting to a different site: {$redirectOrigin}");
|
||||
$shouldContinue = $this->confirm("Do you trust downloading the module from this site?");
|
||||
if (!$shouldContinue) {
|
||||
$this->error("Stopping module installation");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$currentLocation = $redirectLocation;
|
||||
$redirectCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
} while (true);
|
||||
|
||||
if ($resp->getStatusCode() >= 300) {
|
||||
$this->error("ERROR: Failed to download module from {$location}");
|
||||
$this->error("Download failed with status code {$resp->getStatusCode()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'bookstack_module_');
|
||||
$fileHandle = fopen($tempFile, 'w');
|
||||
$respBody = $resp->getBody();
|
||||
$size = 0;
|
||||
$maxSize = 50 * 1024 * 1024;
|
||||
|
||||
while (!$respBody->eof()) {
|
||||
fwrite($fileHandle, $respBody->read(1024));
|
||||
$size += 1024;
|
||||
if ($size > $maxSize) {
|
||||
fclose($fileHandle);
|
||||
unlink($tempFile);
|
||||
$this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB");
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
fclose($fileHandle);
|
||||
|
||||
$this->cleanupActions[] = function () use ($tempFile) {
|
||||
unlink($tempFile);
|
||||
};
|
||||
|
||||
return $tempFile;
|
||||
}
|
||||
|
||||
protected function getPathToZip(string $location): string|null
|
||||
{
|
||||
$lowerLocation = strtolower($location);
|
||||
$isRemote = str_starts_with($lowerLocation, 'http://') || str_starts_with($lowerLocation, 'https://');
|
||||
|
||||
if ($isRemote) {
|
||||
// Warning about fetching from source
|
||||
$host = parse_url($location, PHP_URL_HOST);
|
||||
$this->warn("\nThis will download a module from: {$host}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.");
|
||||
$trustHost = $this->confirm('Are you sure you trust this source?');
|
||||
if (!$trustHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if the connection is http. If so, warn the user.
|
||||
if (str_starts_with($lowerLocation, 'http://')) {
|
||||
$this->warn("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks.");
|
||||
if (!$this->confirm('Are you sure you want to continue without HTTPS?')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Download ZIP and get its location
|
||||
return $this->downloadModuleFile($location);
|
||||
}
|
||||
|
||||
// Validate the file and get the full location
|
||||
$zipPath = realpath($location);
|
||||
|
||||
if (!$zipPath || !is_file($zipPath)) {
|
||||
$this->error("ERROR: Module file not found at {$location}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->warn("\nThis will install a module from: {$zipPath}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.");
|
||||
$trustHost = $this->confirm('Are you sure you want to install this module?');
|
||||
if (!$trustHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $zipPath;
|
||||
}
|
||||
|
||||
protected function cleanup(): void
|
||||
{
|
||||
foreach ($this->cleanupActions as $action) {
|
||||
$action();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,14 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -21,6 +24,7 @@ class BookApiController extends ApiController
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookQueries $queries,
|
||||
protected PageQueries $pageQueries,
|
||||
protected BookshelfQueries $shelfQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -60,13 +64,20 @@ class BookApiController extends ApiController
|
||||
* View the details of a single book.
|
||||
* The response data will contain a 'content' property listing the chapter and pages directly within, in
|
||||
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level
|
||||
* contents will have a 'type' property to distinguish between pages & chapters.
|
||||
* contents will have a 'type' property to distinguish between pages and chapters.
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$book = $this->forJsonDisplay($book);
|
||||
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
|
||||
$book->load([
|
||||
'createdBy',
|
||||
'updatedBy',
|
||||
'ownedBy',
|
||||
'shelves' => function (BelongsToMany $query) {
|
||||
$query->select(['id', 'name', 'slug'])->scopes('visible');
|
||||
}
|
||||
]);
|
||||
|
||||
$contents = (new BookContents($book))->getTree(true, false)->all();
|
||||
$contentsApiData = (new ApiEntityListFormatter($contents))
|
||||
|
||||
@@ -144,7 +144,7 @@ class BookController extends Controller
|
||||
|
||||
View::incrementFor($book);
|
||||
if ($request->has('shelf')) {
|
||||
$this->shelfContext->setShelfContext(intval($request->get('shelf')));
|
||||
$this->shelfContext->setShelfContext(intval($request->input('shelf')));
|
||||
}
|
||||
|
||||
$this->setPageTitle($book->getShortName());
|
||||
@@ -224,9 +224,14 @@ class BookController extends Controller
|
||||
{
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission(Permission::BookDelete, $book);
|
||||
$contextShelf = $this->shelfContext->getContextualShelfForBook($book);
|
||||
|
||||
$this->bookRepo->destroy($book);
|
||||
|
||||
if ($contextShelf) {
|
||||
return redirect($contextShelf->getUrl());
|
||||
}
|
||||
|
||||
return redirect('/books');
|
||||
}
|
||||
|
||||
@@ -258,7 +263,7 @@ class BookController extends Controller
|
||||
$this->checkOwnablePermission(Permission::BookView, $book);
|
||||
$this->checkPermission(Permission::BookCreateAll);
|
||||
|
||||
$newName = $request->get('name') ?: $book->name;
|
||||
$newName = $request->input('name') ?: $book->name;
|
||||
$bookCopy = $cloner->cloneBook($book, $newName);
|
||||
$this->showSuccessNotification(trans('entities.books_copy_success'));
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class BookshelfApiController extends ApiController
|
||||
$this->checkPermission(Permission::BookshelfCreateAll);
|
||||
$requestData = $this->validate($request, $this->rules()['create']);
|
||||
|
||||
$bookIds = $request->get('books', []);
|
||||
$bookIds = $request->input('books', []);
|
||||
$shelf = $this->bookshelfRepo->create($requestData, $bookIds);
|
||||
|
||||
return response()->json($this->forJsonDisplay($shelf));
|
||||
@@ -88,7 +88,7 @@ class BookshelfApiController extends ApiController
|
||||
$this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf);
|
||||
|
||||
$requestData = $this->validate($request, $this->rules()['update']);
|
||||
$bookIds = $request->get('books', null);
|
||||
$bookIds = $request->input('books', null);
|
||||
|
||||
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class BookshelfController extends Controller
|
||||
'tags' => ['array'],
|
||||
]);
|
||||
|
||||
$bookIds = explode(',', $request->get('books', ''));
|
||||
$bookIds = explode(',', $request->input('books', ''));
|
||||
$shelf = $this->shelfRepo->create($validated, $bookIds);
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
@@ -196,7 +196,7 @@ class BookshelfController extends Controller
|
||||
unset($validated['image']);
|
||||
}
|
||||
|
||||
$bookIds = explode(',', $request->get('books', ''));
|
||||
$bookIds = explode(',', $request->input('books', ''));
|
||||
$shelf = $this->shelfRepo->update($shelf, $validated, $bookIds);
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
|
||||
@@ -64,7 +64,7 @@ class ChapterApiController extends ApiController
|
||||
{
|
||||
$requestData = $this->validate($request, $this->rules['create']);
|
||||
|
||||
$bookId = $request->get('book_id');
|
||||
$bookId = $request->input('book_id');
|
||||
$book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId));
|
||||
$this->checkOwnablePermission(Permission::ChapterCreate, $book);
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ class ChapterController extends Controller
|
||||
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
|
||||
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
|
||||
|
||||
$entitySelection = $request->get('entity_selection', null);
|
||||
$entitySelection = $request->input('entity_selection', null);
|
||||
if ($entitySelection === null || $entitySelection === '') {
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
@@ -248,7 +248,7 @@ class ChapterController extends Controller
|
||||
{
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
|
||||
$entitySelection = $request->get('entity_selection') ?: null;
|
||||
$entitySelection = $request->input('entity_selection') ?: null;
|
||||
$newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent();
|
||||
|
||||
if (!$newParentBook instanceof Book) {
|
||||
@@ -259,7 +259,7 @@ class ChapterController extends Controller
|
||||
|
||||
$this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook);
|
||||
|
||||
$newName = $request->get('name') ?: $chapter->name;
|
||||
$newName = $request->input('name') ?: $chapter->name;
|
||||
$chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
|
||||
$this->showSuccessNotification(trans('entities.chapters_copy_success'));
|
||||
|
||||
|
||||
@@ -74,9 +74,9 @@ class PageApiController extends ApiController
|
||||
$this->validate($request, $this->rules['create']);
|
||||
|
||||
if ($request->has('chapter_id')) {
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id')));
|
||||
} else {
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id')));
|
||||
}
|
||||
$this->checkOwnablePermission(Permission::PageCreate, $parent);
|
||||
|
||||
@@ -133,9 +133,9 @@ class PageApiController extends ApiController
|
||||
|
||||
$parent = null;
|
||||
if ($request->has('chapter_id')) {
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id')));
|
||||
} elseif ($request->has('book_id')) {
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id')));
|
||||
}
|
||||
|
||||
if ($parent && !$parent->matches($page->getParent())) {
|
||||
|
||||
@@ -21,6 +21,8 @@ use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -86,7 +88,7 @@ class PageController extends Controller
|
||||
|
||||
$page = $this->pageRepo->getNewDraftPage($parent);
|
||||
$this->pageRepo->publishDraft($page, [
|
||||
'name' => $request->get('name'),
|
||||
'name' => $request->input('name'),
|
||||
]);
|
||||
|
||||
return redirect($page->getUrl('/edit'));
|
||||
@@ -173,7 +175,7 @@ class PageController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page from an ajax request.
|
||||
* Get a page from an ajax request.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
@@ -183,6 +185,10 @@ class PageController extends Controller
|
||||
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
|
||||
$page->makeHidden(['book']);
|
||||
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$page->html = $filter->filterString($page->html);
|
||||
|
||||
return response()->json($page);
|
||||
}
|
||||
|
||||
@@ -402,7 +408,7 @@ class PageController extends Controller
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
$this->checkOwnablePermission(Permission::PageDelete, $page);
|
||||
|
||||
$entitySelection = $request->get('entity_selection', null);
|
||||
$entitySelection = $request->input('entity_selection', null);
|
||||
if ($entitySelection === null || $entitySelection === '') {
|
||||
return redirect($page->getUrl());
|
||||
}
|
||||
@@ -447,7 +453,7 @@ class PageController extends Controller
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission(Permission::PageView, $page);
|
||||
|
||||
$entitySelection = $request->get('entity_selection') ?: null;
|
||||
$entitySelection = $request->input('entity_selection') ?: null;
|
||||
$newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent();
|
||||
|
||||
if (!$newParent instanceof Book && !$newParent instanceof Chapter) {
|
||||
@@ -458,7 +464,7 @@ class PageController extends Controller
|
||||
|
||||
$this->checkOwnablePermission(Permission::PageCreate, $newParent);
|
||||
|
||||
$newName = $request->get('name') ?: $page->name;
|
||||
$newName = $request->input('name') ?: $page->name;
|
||||
$pageCopy = $cloner->clonePage($page, $newParent, $newName);
|
||||
$this->showSuccessNotification(trans('entities.pages_copy_success'));
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
use Ssddanbrown\HtmlDiff\Diff;
|
||||
@@ -32,6 +34,7 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function index(Request $request, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([
|
||||
'id' => trans('entities.pages_revisions_sort_number')
|
||||
@@ -63,6 +66,8 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function show(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
@@ -92,6 +97,8 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function changes(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
@@ -101,12 +108,15 @@ class PageRevisionController extends Controller
|
||||
|
||||
$prev = $revision->getPreviousRevision();
|
||||
$prevContent = $prev->html ?? '';
|
||||
$diff = Diff::excecute($prevContent, $revision->html);
|
||||
|
||||
// TODO - Refactor PageContent so we can de-dupe these steps
|
||||
$rawDiff = Diff::excecute($prevContent, $revision->html);
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$diff = $filter->filterString($rawDiff);
|
||||
|
||||
$page->fill($revision->toArray());
|
||||
// TODO - Refactor PageContent so we don't need to juggle this
|
||||
$page->html = $revision->html;
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$page->html = '';
|
||||
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
|
||||
|
||||
return view('pages.revision', [
|
||||
@@ -124,6 +134,7 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function restore(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
|
||||
@@ -139,6 +150,7 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function destroy(string $bookSlug, string $pageSlug, int $revId)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission(Permission::PageDelete, $page);
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ class PageTemplateController extends Controller
|
||||
*/
|
||||
public function list(Request $request)
|
||||
{
|
||||
$page = $request->get('page', 1);
|
||||
$search = $request->get('search', '');
|
||||
$page = $request->input('page', 1);
|
||||
$search = $request->input('search', '');
|
||||
$count = 10;
|
||||
|
||||
$query = $this->pageQueries->visibleTemplates()
|
||||
|
||||
@@ -17,7 +17,7 @@ use Illuminate\Support\Collection;
|
||||
*
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
* @property int $image_id
|
||||
* @property ?int $image_id
|
||||
* @property ?int $default_template_id
|
||||
* @property ?int $sort_rule_id
|
||||
* @property \Illuminate\Database\Eloquent\Collection $chapters
|
||||
|
||||
@@ -19,7 +19,7 @@ class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInter
|
||||
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
|
||||
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
|
||||
protected $fillable = ['name'];
|
||||
|
||||
/**
|
||||
|
||||
@@ -479,6 +479,7 @@ abstract class Entity extends Model implements
|
||||
'chapter' => new Chapter(),
|
||||
'book' => new Book(),
|
||||
'bookshelf' => new Bookshelf(),
|
||||
default => throw new \InvalidArgumentException("Invalid entity type: {$type}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
* @property bool $draft
|
||||
* @property int $revision_count
|
||||
* @property string $editor
|
||||
* @property Chapter $chapter
|
||||
* @property Chapter|null $chapter
|
||||
* @property Collection $attachments
|
||||
* @property Collection $revisions
|
||||
* @property PageRevision $currentRevision
|
||||
|
||||
@@ -16,6 +16,7 @@ use BookStack\References\ReferenceUpdater;
|
||||
use BookStack\Sorting\BookSorter;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Util\HtmlDescriptionFilter;
|
||||
use BookStack\Util\HtmlToPlainText;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class BaseRepo
|
||||
@@ -151,9 +152,10 @@ class BaseRepo
|
||||
}
|
||||
|
||||
if (isset($input['description_html'])) {
|
||||
$plainTextConverter = new HtmlToPlainText();
|
||||
$entity->descriptionInfo()->set(
|
||||
HtmlDescriptionFilter::filterFromString($input['description_html']),
|
||||
html_entity_decode(strip_tags($input['description_html']))
|
||||
$plainTextConverter->convert($input['description_html']),
|
||||
);
|
||||
} else if (isset($input['description'])) {
|
||||
$entity->descriptionInfo()->set('', $input['description']);
|
||||
|
||||
@@ -60,7 +60,7 @@ class PageRepo
|
||||
$page->book_id = $parent->id;
|
||||
}
|
||||
|
||||
$defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get();
|
||||
$defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book->defaultTemplate()->get();
|
||||
if ($defaultTemplate) {
|
||||
$page->forceFill([
|
||||
'html' => $defaultTemplate->html,
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
|
||||
class EntityHtmlDescription
|
||||
{
|
||||
@@ -50,7 +51,13 @@ class EntityHtmlDescription
|
||||
return $html;
|
||||
}
|
||||
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($html);
|
||||
$isEmpty = empty(trim(strip_tags($html)));
|
||||
if ($isEmpty) {
|
||||
return '<p></p>';
|
||||
}
|
||||
|
||||
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
|
||||
return $filter->filterString($html);
|
||||
}
|
||||
|
||||
public function getPlain(): string
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\App\AppVersion;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
@@ -13,7 +14,9 @@ use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Users\Models\User;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use BookStack\Util\HtmlToPlainText;
|
||||
use BookStack\Util\WebSafeMimeSniffer;
|
||||
use Closure;
|
||||
use DOMElement;
|
||||
@@ -37,7 +40,14 @@ class PageContent
|
||||
public function setNewHTML(string $html, User $updater): void
|
||||
{
|
||||
$html = $this->extractBase64ImagesFromHtml($html, $updater);
|
||||
$this->page->html = $this->formatHtml($html);
|
||||
$html = $this->formatHtml($html);
|
||||
|
||||
$themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_PRE_STORE, $html, $this->page);
|
||||
if (is_string($themeResult)) {
|
||||
$html = $themeResult;
|
||||
}
|
||||
|
||||
$this->page->html = $html;
|
||||
$this->page->text = $this->toPlainText();
|
||||
$this->page->markdown = '';
|
||||
}
|
||||
@@ -50,7 +60,14 @@ class PageContent
|
||||
$markdown = $this->extractBase64ImagesFromMarkdown($markdown, $updater);
|
||||
$this->page->markdown = $markdown;
|
||||
$html = (new MarkdownToHtml($markdown))->convert();
|
||||
$this->page->html = $this->formatHtml($html);
|
||||
$html = $this->formatHtml($html);
|
||||
|
||||
$themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_PRE_STORE, $html, $this->page);
|
||||
if (is_string($themeResult)) {
|
||||
$html = $themeResult;
|
||||
}
|
||||
|
||||
$this->page->html = $html;
|
||||
$this->page->text = $this->toPlainText();
|
||||
}
|
||||
|
||||
@@ -79,7 +96,7 @@ class PageContent
|
||||
|
||||
/**
|
||||
* Convert all inline base64 content to uploaded image files.
|
||||
* Regex is used to locate the start of data-uri definitions then
|
||||
* Regex is used to locate the start of data-uri definitions, then
|
||||
* manual looping over content is done to parse the whole data uri.
|
||||
* Attempting to capture the whole data uri using regex can cause PHP
|
||||
* PCRE limits to be hit with larger, multi-MB, files.
|
||||
@@ -287,8 +304,8 @@ class PageContent
|
||||
public function toPlainText(): string
|
||||
{
|
||||
$html = $this->render(true);
|
||||
|
||||
return html_entity_decode(strip_tags($html));
|
||||
$converter = new HtmlToPlainText();
|
||||
return $converter->convert($html);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,7 +316,7 @@ class PageContent
|
||||
$html = $this->page->html ?? '';
|
||||
|
||||
if (empty($html)) {
|
||||
return $html;
|
||||
return $this->handlePostRender('');
|
||||
}
|
||||
|
||||
$doc = new HtmlDocument($html);
|
||||
@@ -317,11 +334,36 @@ class PageContent
|
||||
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
|
||||
}
|
||||
|
||||
if (!config('app.allow_content_scripts')) {
|
||||
HtmlContentFilter::removeScriptsFromDocument($doc);
|
||||
$cacheKey = $this->getContentCacheKey($doc->getBodyInnerHtml());
|
||||
$cached = cache()->get($cacheKey, null);
|
||||
if ($cached !== null) {
|
||||
return $this->handlePostRender($cached);
|
||||
}
|
||||
|
||||
return $doc->getBodyInnerHtml();
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$filtered = $filter->filterDocument($doc);
|
||||
|
||||
$cacheTime = 86400 * 7; // 1 week
|
||||
cache()->put($cacheKey, $filtered, $cacheTime);
|
||||
|
||||
return $this->handlePostRender($filtered);
|
||||
}
|
||||
|
||||
protected function handlePostRender(string $html): string
|
||||
{
|
||||
$themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_POST_RENDER, $html, $this->page);
|
||||
return is_string($themeResult) ? $themeResult : $html;
|
||||
}
|
||||
|
||||
protected function getContentCacheKey(string $html): string
|
||||
{
|
||||
$contentHash = md5($html);
|
||||
$contentId = $this->page->id;
|
||||
$contentTime = $this->page->updated_at->timestamp ?? time();
|
||||
$appVersion = AppVersion::get();
|
||||
$filterConfig = config('app.content_filtering') ?? '';
|
||||
return "page-content-cache::{$filterConfig}::{$appVersion}::{$contentId}::{$contentTime}::{$contentHash}";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,8 @@ use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
|
||||
class PageEditorData
|
||||
{
|
||||
@@ -47,6 +49,7 @@ class PageEditorData
|
||||
$isDraftRevision = false;
|
||||
$this->warnings = [];
|
||||
$editActivity = new PageEditActivity($page);
|
||||
$lastEditorId = $page->updated_by ?? user()->id;
|
||||
|
||||
if ($editActivity->hasActiveEditing()) {
|
||||
$this->warnings[] = $editActivity->activeEditingMessage();
|
||||
@@ -58,11 +61,20 @@ class PageEditorData
|
||||
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
|
||||
$isDraftRevision = true;
|
||||
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
|
||||
$lastEditorId = $userDraft->created_by;
|
||||
}
|
||||
|
||||
// Get editor type and handle changes
|
||||
$editorType = $this->getEditorType($page);
|
||||
$this->updateContentForEditor($page, $editorType);
|
||||
|
||||
// Filter HTML content if required
|
||||
if ($editorType->isHtmlBased() && !old('html') && $lastEditorId !== user()->id) {
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$page->html = $filter->filterString($page->html);
|
||||
}
|
||||
|
||||
return [
|
||||
'page' => $page,
|
||||
'book' => $page->book,
|
||||
|
||||
@@ -20,8 +20,8 @@ class PermissionsUpdater
|
||||
*/
|
||||
public function updateFromPermissionsForm(Entity $entity, Request $request): void
|
||||
{
|
||||
$permissions = $request->get('permissions', null);
|
||||
$ownerId = $request->get('owned_by', null);
|
||||
$permissions = $request->input('permissions', null);
|
||||
$ownerId = $request->input('owned_by', null);
|
||||
|
||||
$entity->permissions()->delete();
|
||||
|
||||
@@ -47,7 +47,7 @@ class PermissionsUpdater
|
||||
{
|
||||
if (isset($data['role_permissions'])) {
|
||||
$entity->permissions()->where('role_id', '!=', 0)->delete();
|
||||
$rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['role_permissions'] ?? [], false);
|
||||
$rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['role_permissions'], false);
|
||||
$entity->permissions()->createMany($rolePermissionData);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\CspService;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use BookStack\Util\HtmlToPlainText;
|
||||
use DOMElement;
|
||||
use Exception;
|
||||
use Throwable;
|
||||
@@ -208,7 +209,7 @@ class ExportFormatter
|
||||
preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
|
||||
|
||||
// Replace image src with base64 encoded image strings
|
||||
if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
|
||||
if (count($imageTagsOutput[0]) > 0) {
|
||||
foreach ($imageTagsOutput[0] as $index => $imgMatch) {
|
||||
$oldImgTagString = $imgMatch;
|
||||
$srcString = $imageTagsOutput[2][$index];
|
||||
@@ -225,7 +226,7 @@ class ExportFormatter
|
||||
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
|
||||
|
||||
// Update relative links to be absolute, with instance url
|
||||
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
|
||||
if (count($linksOutput[0]) > 0) {
|
||||
foreach ($linksOutput[0] as $index => $linkMatch) {
|
||||
$oldLinkString = $linkMatch;
|
||||
$srcString = $linksOutput[2][$index];
|
||||
@@ -242,24 +243,13 @@ class ExportFormatter
|
||||
|
||||
/**
|
||||
* Converts the page contents into simple plain text.
|
||||
* This method filters any bad looking content to provide a nice final output.
|
||||
* We re-generate the plain text from HTML at this point, post-page-content rendering.
|
||||
*/
|
||||
public function pageToPlainText(Page $page, bool $pageRendered = false, bool $fromParent = false): string
|
||||
{
|
||||
$html = $pageRendered ? $page->html : (new PageContent($page))->render();
|
||||
// Add proceeding spaces before tags so spaces remain between
|
||||
// text within elements after stripping tags.
|
||||
$html = str_replace('<', " <", $html);
|
||||
$text = trim(strip_tags($html));
|
||||
// Replace multiple spaces with single spaces
|
||||
$text = preg_replace('/ {2,}/', ' ', $text);
|
||||
// Reduce multiple horrid whitespace characters.
|
||||
$text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text);
|
||||
$text = html_entity_decode($text);
|
||||
// Add title
|
||||
$text = $page->name . ($fromParent ? "\n" : "\n\n") . $text;
|
||||
|
||||
return $text;
|
||||
$contentText = (new HtmlToPlainText())->convert($html);
|
||||
return $page->name . ($fromParent ? "\n" : "\n\n") . $contentText;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,7 +257,7 @@ class ExportFormatter
|
||||
*/
|
||||
public function chapterToPlainText(Chapter $chapter): string
|
||||
{
|
||||
$text = $chapter->name . "\n" . $chapter->description;
|
||||
$text = $chapter->name . "\n" . $chapter->descriptionInfo()->getPlain();
|
||||
$text = trim($text) . "\n\n";
|
||||
|
||||
$parts = [];
|
||||
@@ -323,7 +313,7 @@ class ExportFormatter
|
||||
$text .= $description . "\n\n";
|
||||
}
|
||||
|
||||
foreach ($chapter->pages as $page) {
|
||||
foreach ($chapter->getVisiblePages() as $page) {
|
||||
$text .= $this->pageToMarkdown($page) . "\n\n";
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace BookStack\Exports;
|
||||
|
||||
use BookStack\Exceptions\PdfExportException;
|
||||
use Dompdf\Dompdf;
|
||||
use FontLib\Font;
|
||||
use Illuminate\Support\Str;
|
||||
use Knp\Snappy\Pdf as SnappyPdf;
|
||||
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
||||
use Symfony\Component\Process\Process;
|
||||
@@ -60,12 +62,65 @@ class PdfGenerator
|
||||
$domPdf = new Dompdf($options);
|
||||
$domPdf->setBasePath(base_path('public'));
|
||||
|
||||
$fontMetrics = $domPdf->getFontMetrics();
|
||||
$userFontfamilies = $this->getUserDomPdfFontFamilies();
|
||||
foreach ($userFontfamilies as $fontFamily => $fonts) {
|
||||
try {
|
||||
$fontMetrics->setFontFamily($fontFamily, $fonts);
|
||||
} catch (\Exception $exception) {
|
||||
$expectedPath = storage_path('fonts/dompdf');
|
||||
throw new PdfExportException("Failed to create required font data in {$expectedPath}, Ensure all content in this location is writable by the web server");
|
||||
}
|
||||
}
|
||||
|
||||
$domPdf->loadHTML($this->convertEntities($html));
|
||||
$domPdf->render();
|
||||
|
||||
return (string) $domPdf->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
protected function getUserDomPdfFontFamilies(): array
|
||||
{
|
||||
$fontStore = storage_path('fonts/dompdf');
|
||||
if (!is_dir($fontStore)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$fontFamilies = [];
|
||||
$fontFiles = glob($fontStore . DIRECTORY_SEPARATOR . '*.ttf');
|
||||
foreach ($fontFiles as $fontFile) {
|
||||
$fontFileName = basename($fontFile, '.ttf');
|
||||
$expectedUfm = $fontStore . DIRECTORY_SEPARATOR . $fontFileName . '.ufm';
|
||||
if (!file_exists($expectedUfm)) {
|
||||
$font = Font::load($fontFile);
|
||||
$font->parse();
|
||||
try {
|
||||
$font->saveAdobeFontMetrics($expectedUfm);
|
||||
} catch (\Exception $exception) {
|
||||
throw new PdfExportException("Failed to create required font data at $expectedUfm, Ensure this location is writable by the web server");
|
||||
}
|
||||
}
|
||||
|
||||
$nameParts = explode('-', $fontFileName);
|
||||
if (count($nameParts) === 1 || $nameParts[1] === 'Regular') {
|
||||
$nameParts[1] = 'Normal';
|
||||
}
|
||||
|
||||
$family = trim(strtolower(preg_replace('/([A-Z])/', ' $1', $nameParts[0])));
|
||||
$variation = Str::snake($nameParts[1]);
|
||||
if (!isset($fontFamilies[$family])) {
|
||||
$fontFamilies[$family] = [];
|
||||
}
|
||||
|
||||
$fontFamilies[$family][$variation] = $fontStore . DIRECTORY_SEPARATOR . $fontFileName;
|
||||
}
|
||||
|
||||
return $fontFamilies;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws PdfExportException
|
||||
*/
|
||||
|
||||
@@ -45,7 +45,7 @@ final class ZipExportAttachment extends ZipExportModel
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'link' => ['required_without:file', 'nullable', 'string'],
|
||||
'link' => ['required_without:file', 'nullable', 'string', 'max:2000', 'safe_url'],
|
||||
'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()],
|
||||
];
|
||||
|
||||
|
||||
@@ -82,10 +82,8 @@ class ZipImportRunner
|
||||
$entity = $this->importBook($exportModel, $reader);
|
||||
} else if ($exportModel instanceof ZipExportChapter) {
|
||||
$entity = $this->importChapter($exportModel, $parent, $reader);
|
||||
} else if ($exportModel instanceof ZipExportPage) {
|
||||
$entity = $this->importPage($exportModel, $parent, $reader);
|
||||
} else {
|
||||
throw new ZipImportException(['No importable data found in import data.']);
|
||||
$entity = $this->importPage($exportModel, $parent, $reader);
|
||||
}
|
||||
|
||||
$this->references->replaceReferences();
|
||||
@@ -132,7 +130,7 @@ class ZipImportRunner
|
||||
'name' => $exportBook->name,
|
||||
'description_html' => $exportBook->description_html ?? '',
|
||||
'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader) : null,
|
||||
'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
|
||||
'tags' => $this->exportTagsToInputArray($exportBook->tags),
|
||||
]);
|
||||
|
||||
if ($book->coverInfo()->getImage()) {
|
||||
@@ -151,7 +149,7 @@ class ZipImportRunner
|
||||
foreach ($children as $child) {
|
||||
if ($child instanceof ZipExportChapter) {
|
||||
$this->importChapter($child, $book, $reader);
|
||||
} else if ($child instanceof ZipExportPage) {
|
||||
} else {
|
||||
$this->importPage($child, $book, $reader);
|
||||
}
|
||||
}
|
||||
@@ -166,7 +164,7 @@ class ZipImportRunner
|
||||
$chapter = $this->chapterRepo->create([
|
||||
'name' => $exportChapter->name,
|
||||
'description_html' => $exportChapter->description_html ?? '',
|
||||
'tags' => $this->exportTagsToInputArray($exportChapter->tags ?? []),
|
||||
'tags' => $this->exportTagsToInputArray($exportChapter->tags),
|
||||
], $parent);
|
||||
|
||||
$exportPages = $exportChapter->pages;
|
||||
@@ -199,7 +197,7 @@ class ZipImportRunner
|
||||
'name' => $exportPage->name,
|
||||
'markdown' => $exportPage->markdown ?? '',
|
||||
'html' => $exportPage->html ?? '',
|
||||
'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
|
||||
'tags' => $this->exportTagsToInputArray($exportPage->tags),
|
||||
]);
|
||||
|
||||
$this->references->addPage($page, $exportPage);
|
||||
@@ -302,7 +300,7 @@ class ZipImportRunner
|
||||
array_push($chapters, ...$exportModel->chapters);
|
||||
} else if ($exportModel instanceof ZipExportChapter) {
|
||||
$chapters[] = $exportModel;
|
||||
} else if ($exportModel instanceof ZipExportPage) {
|
||||
} else {
|
||||
$pages[] = $exportModel;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,10 +68,6 @@ class ZipReferenceParser
|
||||
$matches = [];
|
||||
preg_match_all($referenceRegex, $content, $matches);
|
||||
|
||||
if (count($matches) < 3) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
for ($i = 0; $i < count($matches[0]); $i++) {
|
||||
$referenceText = $matches[0][$i];
|
||||
$type = strtolower($matches[1][$i]);
|
||||
|
||||
@@ -20,10 +20,14 @@ abstract class ApiController extends Controller
|
||||
* Provide a paginated listing JSON response in a standard format
|
||||
* taking into account any pagination parameters passed by the user.
|
||||
*/
|
||||
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = []): JsonResponse
|
||||
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = [], array $filterableFields = []): JsonResponse
|
||||
{
|
||||
$listing = new ListingResponseBuilder($query, request(), $fields);
|
||||
|
||||
if (count($filterableFields) > 0) {
|
||||
$listing->setFilterableFields($filterableFields);
|
||||
}
|
||||
|
||||
foreach ($modifiers as $modifier) {
|
||||
$listing->modifyResults($modifier);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ abstract class Controller extends BaseController
|
||||
*/
|
||||
protected function checkPermission(string|Permission $permission): void
|
||||
{
|
||||
if (!user() || !user()->can($permission)) {
|
||||
if (!user()->can($permission)) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
}
|
||||
@@ -167,14 +167,26 @@ abstract class Controller extends BaseController
|
||||
|
||||
/**
|
||||
* Redirect to the URL provided in the request as a '_return' parameter.
|
||||
* Will check that the parameter leads to a URL under the root path of the system.
|
||||
* Will check that the parameter leads to a URL under the same origin as the application.
|
||||
*/
|
||||
protected function redirectToRequest(Request $request): RedirectResponse
|
||||
{
|
||||
$basePath = url('/');
|
||||
$returnUrl = $request->input('_return') ?? $basePath;
|
||||
|
||||
if (!str_starts_with($returnUrl, $basePath)) {
|
||||
// Only allow use of _return on requests where we expect CSRF to be active
|
||||
// to prevent it potentially being used as an open redirect
|
||||
$allowedMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
||||
if (!in_array($request->getMethod(), $allowedMethods)) {
|
||||
return redirect($basePath);
|
||||
}
|
||||
|
||||
$intendedUrl = parse_url($returnUrl);
|
||||
$baseUrl = parse_url($basePath);
|
||||
$isSameOrigin = ($intendedUrl['host'] ?? '') === ($baseUrl['host'] ?? '')
|
||||
&& ($intendedUrl['scheme'] ?? '') === ($baseUrl['scheme'] ?? '')
|
||||
&& ($intendedUrl['port'] ?? 0) === ($baseUrl['port'] ?? 0);
|
||||
if (!$isSameOrigin) {
|
||||
return redirect($basePath);
|
||||
}
|
||||
|
||||
|
||||
@@ -102,12 +102,15 @@ class DownloadResponseFactory
|
||||
protected function getHeaders(string $fileName, int $fileSize, string $mime = 'application/octet-stream'): array
|
||||
{
|
||||
$disposition = ($mime === 'application/octet-stream') ? 'attachment' : 'inline';
|
||||
$downloadName = str_replace('"', '', $fileName);
|
||||
|
||||
$downloadName = str_replace(['"', '/', '\\', '$'], '', $fileName);
|
||||
$downloadName = preg_replace('/[\x00-\x1F\x7F]/', '', $downloadName);
|
||||
$encodedDownloadName = rawurlencode($downloadName);
|
||||
|
||||
return [
|
||||
'Content-Type' => $mime,
|
||||
'Content-Length' => $fileSize,
|
||||
'Content-Disposition' => "{$disposition}; filename=\"{$downloadName}\"",
|
||||
'Content-Disposition' => "{$disposition}; filename*=UTF-8''{$encodedDownloadName}",
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class ApiAuthenticate
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
// Validate the token and it's users API access
|
||||
$this->ensureAuthorizedBySessionOrToken();
|
||||
$this->ensureAuthorizedBySessionOrToken($request);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
@@ -28,22 +28,28 @@ class ApiAuthenticate
|
||||
*
|
||||
* @throws ApiAuthException
|
||||
*/
|
||||
protected function ensureAuthorizedBySessionOrToken(): void
|
||||
protected function ensureAuthorizedBySessionOrToken(Request $request): void
|
||||
{
|
||||
// Return if the user is already found to be signed in via session-based auth.
|
||||
// This is to make it easy to browser the API via browser after just logging into the system.
|
||||
if (!user()->isGuest() || session()->isStarted()) {
|
||||
// Use the active user session already exists.
|
||||
// This is to make it easy to explore API endpoints via the UI.
|
||||
if (session()->isStarted()) {
|
||||
// Ensure the user has API access permission
|
||||
if (!$this->sessionUserHasApiAccess()) {
|
||||
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
||||
}
|
||||
|
||||
// Only allow GET requests for cookie-based API usage
|
||||
if ($request->method() !== 'GET') {
|
||||
throw new ApiAuthException(trans('errors.api_cookie_auth_only_get'), 403);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Set our api guard to be the default for this request lifecycle.
|
||||
auth()->shouldUse('api');
|
||||
|
||||
// Validate the token and it's users API access
|
||||
// Validate the token and its users API access
|
||||
auth()->authenticate();
|
||||
}
|
||||
|
||||
|
||||
@@ -61,8 +61,7 @@ class JointPermissionBuilder
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BookChild $entity */
|
||||
if ($entity->book) {
|
||||
if ($entity instanceof BookChild) {
|
||||
$entities[] = $entity->book;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,8 @@ enum Permission: string
|
||||
case PageViewAll = 'page-view-all';
|
||||
case PageViewOwn = 'page-view-own';
|
||||
|
||||
case RevisionViewAll = 'revision-view-all';
|
||||
|
||||
/**
|
||||
* Get the generic permissions which may be queried for entities.
|
||||
*/
|
||||
|
||||
@@ -40,9 +40,9 @@ class SearchApiController extends ApiController
|
||||
{
|
||||
$this->validate($request, $this->rules['all']);
|
||||
|
||||
$options = SearchOptions::fromString($request->get('query') ?? '');
|
||||
$page = intval($request->get('page', '0')) ?: 1;
|
||||
$count = min(intval($request->get('count', '0')) ?: 20, 100);
|
||||
$options = SearchOptions::fromString($request->input('query') ?? '');
|
||||
$page = intval($request->input('page', '0')) ?: 1;
|
||||
$count = min(intval($request->input('count', '0')) ?: 20, 100);
|
||||
|
||||
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
|
||||
$this->resultsFormatter->format($results['results']->all(), $options);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user