Compare commits

...

206 Commits

Author SHA1 Message Date
Dan Brown
3ddfa9b948 Meta: Updated security info and fixed some tests/links 2026-04-30 00:32:27 +01:00
Dan Brown
55317039ac Meta: Converted GitHub references in codebase to Codeberg 2026-04-28 09:30:48 +01:00
Dan Brown
24e6087ef8 Meta: Updated readme shields and fixed workflow value 2026-04-27 21:13:05 +01:00
Dan Brown
7c1d30bc8f Translations: Added crowdin workflow action 2026-04-27 20:56:05 +01:00
Dan Brown
c1610c4532 Meta: Migrated repo content to forgejo
Kept some GitHub templates with warnings about the migration.
Made some initial updates to readme for the migration.
2026-04-27 17:48:27 +01:00
Dan Brown
2e2f59fa0f CI: Updated images to debian trixie 2026-04-27 13:36:47 +01:00
Dan Brown
cc6e9e0546 CI: Attempt a more robust avif support check 2026-04-27 13:17:58 +01:00
Dan Brown
0f59981932 CI: Updated tests using DB to set test DB URL 2026-04-27 12:52:05 +01:00
Dan Brown
a37f903dc7 CI: Migrated workflows to forgejo 2026-04-27 12:10:44 +01:00
Dan Brown
74aa897626 Readme: Updated netways sponsor link 2026-04-24 23:16:44 +01:00
Dan Brown
4b624596c8 Merge pull request #6109 from BookStackApp/dompdf_font_loading
PDF: Started building system to allow custom DOMPDF font loading
2026-04-22 13:30:48 +01:00
Dan Brown
00239bb6c8 Exports: Improved dompdf font loading permission errors 2026-04-22 13:22:20 +01:00
Dan Brown
241563e8fc Exports: Added testing coverage for DOMPDF font usage 2026-04-22 13:12:34 +01:00
Dan Brown
e91747785b PDF: Started building system to allow custom DOMPDF font loading 2026-04-20 15:42:28 +01:00
Dan Brown
4f370ccddb Styles: Aligned fonts set on content and headers for exports
During review of #6069
2026-04-20 14:32:13 +01:00
Dan Brown
743a21a02f Merge branch 'fix/pdf-export-heading-fonts' of github.com:alexwoo-awso/BookStack into alexwoo-awso-fix/pdf-export-heading-fonts 2026-04-20 14:13:48 +01:00
Dan Brown
0c9fabb6de Merge pull request #6108 from BookStackApp/view_revisions_permission
Permissions: Started addition of revision-view permission
2026-04-19 16:39:29 +01:00
Dan Brown
426f9ac493 Permissions: Prevent export revision metadata view without permission 2026-04-19 16:23:16 +01:00
Dan Brown
ec0b0384a2 Permissions: Tweaks/fixed during review of revision-view-all changes 2026-04-19 16:06:31 +01:00
Dan Brown
e7e019d3d4 Permissions: Added testing coverage for revision-view-all 2026-04-19 15:56:54 +01:00
Dan Brown
1339f668eb Permissions: Added revision-view-all addition migration 2026-04-19 15:32:10 +01:00
Dan Brown
befa3a8fbb Permissions: Started addition of revision-view permission 2026-04-19 12:41:11 +01:00
Dan Brown
083fb1a600 Maintenance: Updated $request->get instance to use input 2026-04-18 20:43:27 +01:00
Dan Brown
a2bb5bdf10 Meta: Updated COC, templates, PR template for community rules
Added reference to new community rules page where sensible.
2026-04-17 21:22:04 +01:00
Dan Brown
e274a5fa4e Merge pull request #6100 from BookStackApp/wysiwyg_minimal_inline_code
WYSIWYG: Added inline code support to minimal editor
2026-04-16 11:25:19 +01:00
Dan Brown
18364d1e6e WYSIWYG: Added inline code support to minimal editor
Used for comments and descriptions.
Also updated shortcut handling that we're not registering shortcuts for
edits which can't use the related formatting types.

For #6003
2026-04-16 11:11:06 +01:00
Dan Brown
0760e677b2 Merge pull request #6095 from BookStackApp/tags_api
API:  New tag endpoints
2026-04-14 12:22:56 +01:00
Dan Brown
208629ee1f API: Some changes to tag API endpoints
- Updated tag values endpoint to use query param instead of path
  argument, so a better range of values can be provided (including those
  with slashes).
- Updated image gallery example request to align with docs use changes.
2026-04-14 12:03:29 +01:00
Dan Brown
346dc27979 API: Added testing to cover tags API endpoints 2026-04-14 11:31:34 +01:00
Dan Brown
1c1ad1d1b7 Tags API: Reviewed docs and added examples 2026-04-12 20:45:18 +01:00
Dan Brown
f14fc68b66 API: Added new tags API endpoints 2026-04-12 18:26:00 +01:00
Dan Brown
93f84a81b2 Merge pull request #6083 from BookStackApp/better_plain_text
New HTML to Plaintext handling
2026-04-12 17:01:45 +01:00
Dan Brown
4feb50e7ee Attachments: Aligned attachment validation a little more 2026-04-12 15:29:00 +01:00
Dan Brown
c7e2b487c1 Attachments: Aligned ZipExportAttachment link validation
With controller routes.
Don't consider this as a security issue, since the filtered URLs
by that validation are very likely to be blocked by browser security
or CSP, and there's a level of assumed privilege to the users that
are able to create such attachments links already.

Closes #6093
2026-04-12 15:17:31 +01:00
Dan Brown
4e3fa4822f Sort Rules: Added creation hints to sort rule selection
To help direct/indicate how rules can be created.
For #5967
2026-04-12 14:31:40 +01:00
Dan Brown
684a94c419 Theme Modules: Prevented zip-slip in new module extraction method
Updated the new (development only) approach which could result in
zip-slip causing trouble. This adds path normalisation, and testing to
cover.
2026-04-11 18:49:34 +01:00
Dan Brown
c3c8577f05 Merge pull request #6094 from BookStackApp/module_command_updates
Install Module Command Updates
2026-04-11 17:38:34 +01:00
Dan Brown
5fbaab4740 Theme modules: Allowed cross-origin redirects on download
With a prompt to the user to confirm they trust the origin.
For #6066
Added tests to cover.
2026-04-11 17:23:11 +01:00
Dan Brown
3d9d5fef51 Theme Modules: Updated install command to handle nested folder
Theme module ZIPs will now support their files being in a single nested
directory within a ZIP, to support common ZIP structure approaches.
Added test to cover.
For #6066
2026-04-11 15:04:53 +01:00
Dan Brown
5e78dc6ed5 Maintenance: Updated PHPStan to Level 4 (#6085) 2026-04-08 21:03:20 +01:00
Dan Brown
c33853ed84 Maintenance: Updated NPM packages (#6090)
* Maintenance: Updated NPM packages

Includes typescript update to 6. Needed to update some typescript config
to align with actual module environment used and built by esbuild.

* Maintenance: Fixed testing issues after NPM dep version changes

* Maintenance: Updated JS test workflow step version

* Maintenance: Updated approach used for TS config in jest config
2026-04-08 21:02:20 +01:00
Dan Brown
e033578fea Updated translator & dependency attribution before release v26.03.3 2026-04-05 22:43:15 +01:00
Dan Brown
a7dd998ac9 Updated translations with latest Crowdin changes (#6067) 2026-04-05 22:29:00 +01:00
Dan Brown
b9d650785a Deps: Updated PHP package versions 2026-04-05 22:28:27 +01:00
Dan Brown
abed4eae0c Exports: Updated plaintext export to use new converter 2026-04-05 17:51:19 +01:00
Dan Brown
c7d3775bb9 Plain text: Created a new HTML to plain text converter
To centralise logic to be more consistent, and to have smarter logic
which avoids just following newline format from input, preventing
smushing HTML elements (like list elements) next to eachother
2026-04-05 00:05:10 +01:00
ololukaszuk
0b659671fe Fix PDF heading font fallback for export 2026-03-25 15:23:15 +01:00
Dan Brown
25790fd024 Merge branch 'sec_26_03_2' into development 2026-03-23 11:24:07 +00:00
Dan Brown
1763ac550b Meta: Updated translators pre v26.03.2 release 2026-03-23 10:08:38 +00:00
Dan Brown
fd6867e577 Updated translations with latest Crowdin changes (#6064) 2026-03-23 10:05:51 +00:00
Dan Brown
5ebc1fe3b0 Deps: Updated PHP package versions pre v26.03.2 release 2026-03-22 17:22:13 +00:00
Dan Brown
a44756168d WYSIWYG: Aligned double click to set label for details functionality
Aligned the behaviour across the WYSIWYG editors, and also for nested
details blocks (which wasn't working in the TinyMCE implementation).

Closes #6059
2026-03-22 17:20:36 +00:00
Dan Brown
fa1dc162bd Update PHP_CodeSniffer repository link (#6060) 2026-03-21 17:13:43 +00:00
Dan Brown
5763d26b17 Updated registration to use validated input instead of all 2026-03-19 21:29:30 +00:00
Rodrigo Primo
04dd9f8e19 Update PHP_CodeSniffer repository link 2026-03-17 17:21:01 -03:00
Dan Brown
0120b475eb Deps: Updated PHP deps pre v26.03.1 2026-03-17 10:59:11 +00:00
Dan Brown
8a59895ba0 Merge branch 'sec_chapter_export' into development 2026-03-17 10:41:51 +00:00
Dan Brown
a9ffd3e0c7 Responses: Added extra sanitization for download names
From testing, don't think this could exploited directly, as the response
would error instead of allowing control characters, but this adds an
extra layer of sanitization, and switches to encoded disposition
filenames for better UTF8 support.
2026-03-16 18:28:44 +00:00
Dan Brown
4f18fea086 Deps: Updated PHP deps pre v26.03 release 2026-03-15 13:17:48 +00:00
Dan Brown
362859ac23 Updated translator & dependency attribution before release v26.03 2026-03-15 13:14:54 +00:00
Dan Brown
7cbfd72920 Merge pull request #6007 from BookStackApp/l10n_development
Updated translations with latest Crowdin changes
2026-03-15 12:58:05 +00:00
Dan Brown
49df47836e Merge pull request #6057 from BookStackApp/v25-12
V25.12 changes v3
2026-03-15 12:51:02 +00:00
Dan Brown
f4c9d2b049 Exports: Fixed scope of pages in chapter MD export
Added tests to cover children of all MD exports
2026-03-13 13:35:28 +00:00
Dan Brown
60a3b0c0ac API examples: Updated books-read to include shelf info 2026-03-12 17:04:36 +00:00
Dan Brown
5f5fea7c83 Deps: Bumped PHP packages before release 2026-03-12 10:52:12 +00:00
Dan Brown
6e7cc169d1 Preferences: Updated return redirect with better origin checks
As suggested by Alex Dan in their security report.
2026-03-10 18:31:51 +00:00
Dan Brown
6216c89f82 Packages: Updated PHP package versions 2026-03-10 17:48:12 +00:00
Dan Brown
404e67afbc Page Revisions: Added testing coverage to basic diffing 2026-03-10 17:47:07 +00:00
Dan Brown
6d64262a61 Revision Diffs: Added filtering post-diff render 2026-03-10 15:03:43 +00:00
Dan Brown
151823b84e Theme Modules: Added easier way to insert HTML head content 2026-03-08 10:26:00 +00:00
Dan Brown
27240be499 Theme System: Added new page-content focused events
Closes #6049
2026-03-06 12:40:22 +00:00
Dan Brown
d0d1bb9829 Merge pull request #6035 from BookStackApp/v25-12
Merge further v25-12 changes into development
2026-03-06 10:26:41 +00:00
Dan Brown
7d0237c798 NPM Deps: Updated package versions
Fixed SCSS if deprecations
Fixed new eslint detected issues
2026-03-06 10:25:27 +00:00
Dan Brown
f2f76a3c56 Modules: Improved install command based on testing
- Updated output to be clearer
- Added warning and confirmation to local install flow
- Adjusted module folder name creation
2026-03-06 09:28:46 +00:00
Dan Brown
ec3dd856db Mail: Set domain for EHLO based upon the APP_URL
For #5990
2026-02-28 18:46:05 +00:00
Dan Brown
25ed242f61 Deps: Updated PHP package versions 2026-02-27 10:09:41 +00:00
Dan Brown
10c46534e0 Logical Theme: Added OIDC_AUTH_PRE_REDIRECT event
Allows customization of the auth URL before the user is redirected
to that URL.
Related to #6014
2026-02-27 09:34:33 +00:00
Dan Brown
dd42b9b43f Text: Updated per-page display limits description
To be more sensible & direct as per MtheBird's suggestion.
Closes #6005
2026-02-27 08:54:12 +00:00
Dan Brown
9a12e3a8b7 Book API: Added shelves list to show endpoint
For #6006
Added test to cover.
2026-02-24 10:25:17 +00:00
Dan Brown
7aef0a48b3 Content: Updated filters to allow some required attributes
- Allows target attribute on links.
- Allows custom mention attribute on links.

Adds test case to cover these.
For #6034
2026-02-23 08:08:44 +00:00
Dan Brown
6808292c90 Editors: Made drawings appear clickiable via cursor
During review of #5864
2026-02-21 16:00:14 +00:00
Dan Brown
c10b0fd5b9 Merge branch 'patch-1' of github.com:lublak/BookStack into lublak-patch-1 2026-02-21 15:52:25 +00:00
Dan Brown
1077a4efd0 Merge branch 'v25-12' into development 2026-02-21 13:59:29 +00:00
Dan Brown
23f3f35f6b Readme: Updated sponsors 2026-02-21 13:56:50 +00:00
Dan Brown
229a99ba24 Descriptions: Improved empty field handling, reduces whitespace
For #5724
2026-02-20 14:22:54 +00:00
Dan Brown
8e99fc6783 Books: On delete, redirect to shelf if in context
For #6029
Added tests to cover
2026-02-20 11:23:26 +00:00
Dan Brown
80204518a2 Page Content: Better handling for empty content filtering
For #6028
2026-02-19 23:25:00 +00:00
Dan Brown
a8d96fd389 Content filter: Allowed custom diagram attribute in allow-list
For #6026
2026-02-18 19:33:35 +00:00
Dan Brown
9d15c79fee Deps: Updated PHP package versions 2026-02-18 19:24:06 +00:00
Dan Brown
e1de1f0583 git: Added old purifier location to gitignore 2026-02-17 18:34:14 +00:00
Dan Brown
a2017ffa55 Caching: Altered purifier cache folder to be server-created
Moved from a static folder to a dynamically created folder in the
framework/cache directory, to increase the chance that it's created with
server-writable permissions.
This is due to an issue where users had permission issues, since adding
a new folder means it's created by the git user and often
non-web-writable.
2026-02-17 18:22:13 +00:00
Dan Brown
9646339933 Testing: Addressed failing tests and static checks 2026-02-17 11:31:47 +00:00
Dan Brown
e4383765e1 Meta: Updated licenses and config wording 2026-02-17 11:00:37 +00:00
Dan Brown
5d547fcf4c Deps: Updated PHP packages
Also fixed test namespace
2026-02-17 09:44:56 +00:00
Dan Brown
826b36c985 Editors: Added HTML filtering in certain loading conditions
When loaded via ajax for draft revert live in editor, or when loaded
into the editor by a different user.
2026-02-16 15:50:54 +00:00
Dan Brown
3fa1174e7a Content filtering: Updated config and readme attribution 2026-02-16 13:46:45 +00:00
Dan Brown
50e8501027 Content Filter: Added extra object filtering
Was blocked by CSP anyway, but best to have an extra layer.
2026-02-16 13:02:24 +00:00
Dan Brown
8a221f64e4 Content Filtering: Covered new config options and filters with tests 2026-02-16 10:11:48 +00:00
Dan Brown
035be66ebc Content: Updated tests and CSP usage of content script setting
Updates CSP to use new content_filtering option.
Splits out content filtering tests to their own class.
Updated tests where needed to adapt to changes.
2026-02-15 18:44:14 +00:00
Dan Brown
227027fc45 Content: Updated purifier and content caching
- Updated page content cache to use app version in cache key
- Moved purifier cache into framework to better work with existing
  expected folders.
- Added app version check to purifier so that it will reset its own
  cache on app version change.
2026-02-15 16:46:09 +00:00
Dan Brown
0f040fe8b1 Content: Tuned HTML purifier for our use
Tested it with a range of supported, including uncommon, content types
and added support, or changed config, where needed.
Been through docs for all HTMLPurifier options to assess what's
relevant.
2026-02-15 16:17:03 +00:00
Dan Brown
10ebe53bd9 Page Content: Added more complex & configurable content filtering
- Added new option to control parts of the filter.
- Added whitelist filtering pass via HTMLPurifier.
2026-02-13 14:14:28 +00:00
Dan Brown
5e12b678c7 Merge pull request #5998 from BookStackApp/further_theme_development
Further theme system developments
2026-02-09 13:34:15 +00:00
Dan Brown
057d7be0bc Views: Made index/show sidebars a lot more modular
Split out each sidebar block into their own template for easier
customization of those elements, and less code to manage when overriding
the parent show/index views.
2026-02-08 17:03:48 +00:00
Dan Brown
984a73159f Theme modules: Updated view includes to prevent caching conflicts 2026-02-08 13:39:34 +00:00
Dan Brown
a20438b901 Theme System: Fixed theme view before/after issues
- Updated the system to work with modules.
- Updated module docs to consider namespacing.
- Fixed view loading and registration event ordering.
- Fixed checking if views are registered.
2026-02-07 23:01:13 +00:00
Dan Brown
9d3d0a4a07 Theme Modules: Added testing coverage for install command 2026-02-05 21:57:12 +00:00
Dan Brown
5038d124e1 Theme modules: Updated docs to cover ZIP format 2026-02-05 18:01:17 +00:00
Dan Brown
f7890c2dd9 Theme Modules: Fixes and improvements after manual testing
- Added (limited) redirect handling to module downloads.
- Adjusted wording/text for consistency and clarity.
- Fixed scenarios where process was not stopped on error.
- Fixed module folder creation check/logic.
- Added better failed request handling to module downloads.
- Updated download response streaming to monitor/limit download size.
2026-02-05 17:49:35 +00:00
Dan Brown
45ae03ceac Theme Modules: Added install helper command
Not yet tested at all, either manually or via PHPUnit
2026-02-03 20:43:01 +00:00
Dan Brown
aa0a8dda11 Theme Modules: Added dev documentation 2026-02-02 18:29:35 +00:00
Dan Brown
120ee38383 Theme Modules: Added testing coverage 2026-02-01 17:31:21 +00:00
Dan Brown
cd84074cdf Theme System: Split & organised tests, changed module version to string 2026-02-01 16:27:52 +00:00
Dan Brown
4949520194 Theme System: Added initial module implementations 2026-02-01 11:53:46 +00:00
Dan Brown
46dcc30bf7 Updated translator & dependency attribution before release v25.12.3 2026-01-29 15:18:06 +00:00
Dan Brown
9f7d3b55dd Updated translations with latest Crowdin changes (#5997) 2026-01-29 15:11:40 +00:00
Dan Brown
3e5e88dc87 Deps: Updated PHP package versions via composer 2026-01-29 14:57:05 +00:00
Dan Brown
c77a0fdff3 Page Content: Added form elements to filtering
Added and updated tests to cover.

Also updated API auth to a narrower focus of existing session instead of also existing user auth.
This is mainly for tests, to ensure they're following the session
process we'd see for activity in the UI.
2026-01-29 14:54:08 +00:00
Dan Brown
6a63b38bb3 API: Prevented non-GET requests when using cookie-based auth
Added test to cover.
2026-01-29 03:37:16 +00:00
Dan Brown
1b17bb3929 Theme: Changed how before/after views are registered
Changed the system out to be a theme event instead of method, to align
with other registration events, and so that the theme view work can
better be contained in its own class.
2026-01-27 16:50:50 +00:00
Dan Brown
9fcfc762ec Theme: Added testing of registerViewToRender* functions
Updated function name also.
2026-01-27 00:36:35 +00:00
Dan Brown
c32b1686a9 Theme: Added the ability to add views before/after existing ones
Adds a registration system via the logical theme system, to tell
BookStack about views to render before or after a specific template
is included in the system.
2026-01-26 17:16:14 +00:00
Dan Brown
36649a6188 Theme: Updated view registration to be dynamic
Within the responsibility of the theme service instead
of being part of the app configuration.
2026-01-26 11:55:39 +00:00
Dan Brown
ff59bbdc07 Updated translator & dependency attribution before release v25.12.2 2026-01-24 13:53:55 +00:00
Dan Brown
4dc443b7df Updated translations with latest Crowdin changes (#5970) 2026-01-22 17:53:58 +00:00
Dan Brown
19f02d927e Deps: Updated PHP package versions 2026-01-22 17:39:26 +00:00
Dan Brown
da7bedd2e4 Sponsors: Added Onyx 2026-01-13 13:23:54 +00:00
Dan Brown
20db372596 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2026-01-07 11:10:55 +00:00
Dan Brown
43eed1660c Meta: Updated dev version, license year, crowdin config
Added Id to crowdin config for compatibility with upcoming change to
crowdin CLI process after switch to codeberg
2026-01-07 11:09:39 +00:00
Dan Brown
e6b754fad0 Merge pull request #5969 from shaoliang123456/fix/git-safe-directory-in-docker
Git 2.35+ may refuse to operate on bind-mounted repos with differing ownership ("dubious ownership"), Mark /app as safe within the container.
2026-01-03 17:56:52 +00:00
leon
018de5def3 fix: Configure safe directory for git in dockerfile 2025-12-31 16:20:52 +08:00
leon
5c4fc3dc2c fix: Docker: Add
git safe.directory config for bind-mounted repos.Mark
 /app as safe directory to handle Git 2.35+ ownership
 checks in Docker containers.
2025-12-31 11:53:22 +08:00
Dan Brown
07ec880e33 Testing: Updated search tests to consider new limits 2025-12-30 17:09:26 +00:00
Dan Brown
ab436ed5c3 Updated translations with latest Crowdin changes (#5962) 2025-12-30 16:32:21 +00:00
Dan Brown
082befb2fc Updated PHP packages and translators pre v25.12.1 2025-12-30 16:16:39 +00:00
Dan Brown
b0a8cb0c5d Merge pull request #5968 from BookStackApp/limits
Add some additional resource-based limits
2025-12-30 16:14:04 +00:00
Dan Brown
b08d1b36de Search: Set limits on the amount of search terms
Sets some reasonable limits, which are higher when logged in since that
infers a little extra trust.
Helps prevent against large resource consuption attacks via super heavy
search queries.

Thanks to Gabriel Rodrigues AKA TEXUGO for reporting.
2025-12-30 13:32:14 +00:00
Dan Brown
88d86df66f ZIP Exports: Added limit to ZIP file size before extraction
Checks files within the ZIP again the app upload file limit
before using/streaming/extracting, to help ensure that they do no exceed
what might be expected on that instance, and to prevent disk exhaustion
via things like super high compression ratio files.

Thanks to Jeong Woo Lee (eclipse07077-ljw) for reporting.
2025-12-29 23:08:18 +00:00
Dan Brown
38d3697246 Updated translations with latest Crowdin changes (#5933) 2025-12-24 11:52:56 +00:00
Dan Brown
d93354ff0e Updated licenses and translation attribution pre v25.12 2025-12-24 11:51:37 +00:00
Dan Brown
3336e0c6ae Deps: Updated PHP packages via composer 2025-12-24 11:48:42 +00:00
Dan Brown
8fc9a2af4e Lexical API: Updated docs to reflect public event usage 2025-12-23 18:33:54 +00:00
Dan Brown
8aec571123 Mentions: Fixed some users not showing in mention selector 2025-12-21 18:33:50 +00:00
Dan Brown
382f4db276 Merge pull request #5956 from BookStackApp/system_cli_update_dec25
System CLI: Update to v0.4
2025-12-20 15:48:59 +00:00
Dan Brown
d504b19143 System CLI: Update to v0.4
- The init & update commands will now use download-vendor logic instead
  of using composer to install required PHP packages.
- The init command will now use our source.bookstackapp.com git mirror
  instead of GitHub.
- Updated depenancy PHP package versions.
2025-12-20 15:46:15 +00:00
Dan Brown
d87e8d05c7 Merge pull request #5939 from BookStackApp/lexical_fixes_2512
Lexical fixes for v25.12
2025-12-20 14:05:30 +00:00
Dan Brown
0b48361780 Search: Fixed pagination not considering sub-paths
For #5951
Added test to cover.
2025-12-19 15:15:23 +00:00
Dan Brown
2de3247ae4 Deps: Updated PHP package versions
Includes major version change of antonioribeiro/google2fa which changes
secret length. From manual testing of old MFA secrets and new, this
should not be breaking at all.
2025-12-19 14:22:27 +00:00
Dan Brown
48df2be0d8 DB: Added views->viewable_type index
For #5948
2025-12-19 10:54:09 +00:00
Dan Brown
a4c0556551 Merge pull request #5952 from gerundt/iis-module-link
Update "Microsoft URL Rewrite Module for IIS" download link
2025-12-19 10:24:22 +00:00
Dan Brown
a941d1b403 Comment mentions: Fixed CI and test scenarios 2025-12-18 17:40:05 +00:00
Dan Brown
51f9b63db0 Comment Mentions: Fixed and tweaks during review of changes
- Added advisory on role permission form to advise which allow listing
  of users/roles.
- Updated database config to avoid PHP8.5 deprecation.
- Tweaked migration to remove unused index.
- Fixed test namespace.
2025-12-18 17:15:29 +00:00
Dan Brown
90fc02c57f Esbuild & Mentions: Updated interaction stability and build system
- Updated esbuild system to be module, and fixed build command.
- Reverted module use in package.json by default as this impacted test
  runs/files.
- Updated mention user select:
  - To look better in dark mode.
  - To not remove text after on select.
  - To properly revert/restore focus on enter or cancel.
2025-12-17 21:11:01 +00:00
Tim Gerundt
4aeb571126 Update "Microsoft URL Rewrite Module for IIS" download link 2025-12-17 15:11:29 +01:00
Dan Brown
3d9aba7b1f Mentions: Added coverage for mentions search endpoint 2025-12-17 13:48:20 +00:00
Dan Brown
48cdaab690 Comment Mentions: Added tests to cover back-end functionality 2025-12-17 10:49:12 +00:00
Dan Brown
4f760479c3 Merge branch 'development' into comment_mentions 2025-12-17 09:58:27 +00:00
Dan Brown
9211062e8e Merge pull request #5919 from BookStackApp/v25-11
Merge v25-11 changes into dev
2025-12-17 09:57:49 +00:00
Dan Brown
221c6c7e9f Comment Mentions: Added core back-end logic
- Added new user notification preference, opt-in by default
- Added parser to extract mentions from comment HTML, with tests to
  cover.
- Added notification and notification handling

Not yet tested, needs testing coverage.
2025-12-17 09:57:14 +00:00
Dan Brown
e2f91c2bbb Comment Mentions: Added keyboard nav, worked on design 2025-12-14 17:19:08 +00:00
Dan Brown
147ff00c7a JS: Swapped livereload lib for esbuild livereload setup 2025-12-14 16:25:19 +00:00
Dan Brown
1e768ce33f Lexical: Changed mention to be a decorator node
Allows better selection.
Also updated existing decorator file names to align with classes so
they're easier to find.
Also aligned/fixed decorator constuctor/setup methods.
2025-12-13 17:03:48 +00:00
Dan Brown
313326b32a Updated translator & dependency attribution before release v25.11.6 2025-12-09 20:59:57 +00:00
Dan Brown
1d87b513be Deps: Updated PHP package versions 2025-12-09 20:40:48 +00:00
Dan Brown
9bf9ae9c37 Mentions: Added new endpoint, Built editor list display 2025-12-09 16:56:34 +00:00
Dan Brown
50540e23a1 Lexical: Created mention node, started mention service, split comment editor out 2025-12-08 15:52:21 +00:00
Dan Brown
3e1b0587ec Lexical: Fixed undefined entity selector value
Also added pre-fill of selector search based on selected text range.
2025-12-07 15:07:08 +00:00
Dan Brown
6661ae8178 Lexical: Improved focus control for popup modal forms
Now moves focus to first field on open, and restores focus back to
editor on submit/close.
2025-12-07 15:02:06 +00:00
Dan Brown
1ee5711435 Pagination: Fixed lack of responsiveness causing overflow
For #5920
2025-12-07 13:09:50 +00:00
Dan Brown
08e7ba7064 Images: Made resize errors log with error detail
Closes #5869
2025-12-07 12:54:57 +00:00
Dan Brown
34e747162f Testing: Fixed some incorrect test case names 2025-12-06 23:24:33 +00:00
Dan Brown
10f5ceee35 List page settings: Review of #5606
Updated setting display to show mulitple number inputs under one heading
group.
Updated settings to use general number field form view template.
Updated translations to match display changes, and to advise on counts.
Added page count control for search results.
Added setting service method, to get settings as integers, with
min/max/default control.
Updating sorting group to be names "Lists & Sorting".
Added tests to cover.
2025-12-06 23:10:54 +00:00
Dan Brown
9886bbd3a0 Merge branch 'feature/pagination_settings' of github.com:Xenoamor/BookStack into Xenoamor-feature/pagination_settings 2025-12-06 21:47:13 +00:00
Dan Brown
92a3c22b4c Merge pull request #5928 from BookStackApp/lexical_dev_api
Initial developer API for WYSIWYG editor
2025-12-06 15:32:47 +00:00
Dan Brown
b5246a28f0 Lexical API: Updated docs to align method format 2025-12-06 15:18:28 +00:00
Dan Brown
ab4b1c8efa Lexical API: Reviewed docs, Made toolbar its own UI class 2025-12-05 14:44:19 +00:00
Dan Brown
8890746278 Lexical API: Added public event to access editor API
Updated documentation to match.
Ran manual testing of examples.
2025-12-05 14:07:24 +00:00
Dan Brown
dfdcfcfdb8 Lexical API: Added content module, testing and documented 2025-12-05 12:15:18 +00:00
Dan Brown
ebceba0afe Lexical API: Started working on docs format and jest testing 2025-12-04 21:13:17 +00:00
Dan Brown
65f7b61c1f Sessions: Ignored extra meta/dist content in history tracking
For #5925
Added tests to cover.
Extracted existing test to place with similiar sessions tests
2025-12-03 14:10:09 +00:00
Dan Brown
2fde803c76 Deps: Updated PHP package versions
Needed to update some tests due to charset casing change in Symfony 7.4
2025-12-03 13:55:00 +00:00
Dan Brown
adfac3e30e OIDC: Updated state handling to prevent loss from other requests
Which was occuring in chrome, where background requests to the PWA
manifest, or opensearch, endpoint caused OIDC to fail due to lost state
since it was only flashed to the session.
This persists it with a manual TTL.

Added tests to cover.
Manually tested against Azure.
For #5929
2025-12-03 13:34:00 +00:00
Dan Brown
21730aeb39 Sponsors: Tweaked logo sizing 2025-12-02 18:48:21 +00:00
Dan Brown
75231d2d4a Sponsors: Added Admin Intelligence 2025-12-02 18:46:36 +00:00
Dan Brown
9d732d8dd8 Lexical: Started dev API outline 2025-11-30 17:02:17 +00:00
Dan Brown
9e8088f186 JS: Bumped up JS package versions, and node version 2025-11-30 15:07:23 +00:00
Dan Brown
cf847974d2 Merge pull request #5917 from BookStackApp/copy_references
Internal reference handling on content copying
2025-11-29 20:50:05 +00:00
Dan Brown
3cd3e73f60 Copying: Fixed issue with non-page links to page permalinks
Found during manual testing.
Added test case to cover.
2025-11-29 20:35:16 +00:00
Dan Brown
bb350639c6 Styles: Made non-active dark/light css variables exist by default
This means that it would be possible to jump between light/dark mode
with just the class, and no reload needed.
Not something we'll directly use right now, but may be useful in
customizations.
2025-11-27 21:56:45 +00:00
Dan Brown
9de294343d Notifications: Fixed error on comment notification
Fixes an error where a used relation (entity) on the comment was
resulting in null due to eager loading the notification when
deserializing from the queue, where Laravel was then mis-matching the
names when performing the eager loading.

For #5918
2025-11-25 21:08:45 +00:00
Dan Brown
98a09bcc37 Deps: Updated PHP packages 2025-11-25 19:55:22 +00:00
Dan Brown
959981a676 Copying: Added logic to find & update references 2025-11-25 17:52:26 +00:00
Dan Brown
674bb84fac Copying: Added reference change context tracking
Added core wiring in the cloning logic, just need to implement core
logic in the updater now.
2025-11-25 14:46:36 +00:00
Dan Brown
ba675b6349 Copying: Added tests to cover copy self-references
Logic to make tests pass to follow
2025-11-25 13:52:36 +00:00
Dan Brown
f073994bc3 Testing: Extracted copy tests to their own class 2025-11-25 12:36:33 +00:00
Dan Brown
0f40aeb0d3 Merge pull request #5913 from BookStackApp/slug_history
Slug History Tracking & Usage
2025-11-24 20:29:44 +00:00
Dan Brown
cdd164e3e3 Slugs: Added test to cover history lookup permission usage 2025-11-24 20:04:55 +00:00
Dan Brown
c90816987c Slugs: Rolled out history lookup to other types
Added testing to cover.
Also added batch recording of child slug pairs on book slug changes.
2025-11-24 19:49:34 +00:00
Dan Brown
dd393691b1 Slugs: Added lookup system using history
Switched page lookup to use this.
2025-11-24 13:55:11 +00:00
Dan Brown
dd5375f480 Slugs: Fixed storage bugs, added testing coverage 2025-11-24 10:46:24 +00:00
Dan Brown
291a807d98 Slugs: Added slug recording at points of generation
Also moved some model-level helpers, which used app container
resolution, to be injected services instead.
2025-11-23 23:29:30 +00:00
Dan Brown
e64fc60bdf Slugs: Created history table to track changes 2025-11-23 16:50:37 +00:00
Dan Brown
ad582ab9f8 DB testing: Prevented caching during build
To prevent re-using code when re-run for same branch.
2025-11-22 12:40:30 +00:00
Dan Brown
870f3c58c0 DB Testing: Updated supported DB list, added readme
Also fixed potentally flaky GIf test to be more accurate with single
frame GIF images.
2025-11-22 12:24:36 +00:00
lublak
570ded10fa Set cursor to pointer for drawio diagrams
Add cursor style for drawio diagrams in TinyMCE.
2025-10-31 12:51:04 +01:00
Joshua Booth
b6110ed3cd Add settings for number of books/shelves that will be displayed per page 2025-05-13 20:17:36 +01:00
673 changed files with 13311 additions and 5962 deletions

View File

@@ -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.

View 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
View File

@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: [ssddanbrown]
ko_fi: ssddanbrown

View 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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!

View 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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/

View File

@@ -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
View 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.
---

View File

@@ -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
@@ -512,3 +512,28 @@ David Olsen (dawin) :: Danish
ltnzr :: French
Frank Holler (holler.frank) :: German; German Informal
Korab Arifi (korabidev) :: Albanian
Petr Husák (petrhusak) :: Czech
Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian
Amr (amr3k) :: Arabic
Tahsin Ahmed (tahsinahmed2012) :: Bengali
bojan_che :: Serbian (Cyrillic)
setiawan setiawan (culture.setiawan) :: Indonesian
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
Gabriel Silver (GabrielBSilver) :: Hebrew
Tomas Darius Davainis (Tomasdd) :: Lithuanian
CriedHero :: Chinese Simplified
Henrik (henrik2105) :: Norwegian Bokmal
FoW (fofwisdom) :: Korean
serinf-lauza :: French
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
Shadluk Avan (quldosh) :: Uzbek
Marci (MartonPoto) :: Hungarian
Michał Sadurski (wheeskeey) :: Polish
JanDziaslo :: Polish
Charllys Fernandes (CharllysFernandes) :: Portuguese, Brazilian
Ilgiz Zigangirov (inov8) :: Russian
Max Israelsson (Blezie) :: Swedish
Skiddybison5924 (chris-devel0per) :: German
Veyilla Nightwhisper (Veyilla) :: German
João Barbosa (hypeedd) :: Portuguese
Abcdefg Hijklmn (collatek) :: Korean

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
/node_modules
/.vscode
/composer
/composer.phar
/coverage
Homestead.yaml
.env

View File

@@ -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

View File

@@ -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));

View File

@@ -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)) {

View File

@@ -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);

View File

@@ -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;

View File

@@ -9,11 +9,9 @@ use Illuminate\Http\Request;
class OidcController extends Controller
{
protected OidcService $oidcService;
public function __construct(OidcService $oidcService)
{
$this->oidcService = $oidcService;
public function __construct(
protected OidcService $oidcService
) {
$this->middleware('guard:oidc');
}
@@ -30,7 +28,7 @@ class OidcController extends Controller
return redirect('/login');
}
session()->flash('oidc_state', $loginDetails['state']);
session()->put('oidc_state', time() . ':' . $loginDetails['state']);
return redirect($loginDetails['url']);
}
@@ -41,10 +39,16 @@ class OidcController extends Controller
*/
public function callback(Request $request)
{
$storedState = session()->pull('oidc_state');
$responseState = $request->query('state');
$splitState = explode(':', session()->pull('oidc_state', ':'), 2);
if (count($splitState) !== 2) {
$splitState = [null, null];
}
if ($storedState !== $responseState) {
[$storedStateTime, $storedState] = $splitState;
$threeMinutesAgo = time() - 3 * 60;
if (!$storedState || $storedState !== $responseState || intval($storedStateTime) < $threeMinutesAgo) {
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
return redirect('/login');
@@ -62,7 +66,7 @@ class OidcController extends Controller
}
/**
* Log the user out then start the OIDC RP-initiated logout process.
* Log the user out, then start the OIDC RP-initiated logout process.
*/
public function logout()
{

View File

@@ -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);

View File

@@ -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'));
}
/**

View File

@@ -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;

View File

@@ -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');
}

View File

@@ -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();

View File

@@ -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
{

View File

@@ -71,7 +71,7 @@ class LoginService
}
$lastLoginDetails = $this->getLastLoginAttemptDetails();
$this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false);
$this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember']);
}
/**

View File

@@ -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();
}
/**

View File

@@ -14,10 +14,9 @@ use PragmaRX\Google2FA\Support\Constants;
class TotpService
{
protected $google2fa;
public function __construct(Google2FA $google2fa)
{
public function __construct(
protected Google2FA $google2fa
) {
$this->google2fa = $google2fa;
// Use SHA1 as a default, Personal testing of other options in 2021 found
// many apps lack support for other algorithms yet still will scan
@@ -35,7 +34,7 @@ class TotpService
}
/**
* Generate a TOTP URL from secret key.
* Generate a TOTP URL from a secret key.
*/
public function generateUrl(string $secret, User $user): string
{

View File

@@ -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([

View File

@@ -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');
}
}

View File

@@ -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(),

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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'));
}

View File

@@ -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');

View File

@@ -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()

View File

@@ -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;

View 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',
]);
}
}

View File

@@ -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);

View File

@@ -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;
@@ -41,7 +43,19 @@ class Comment extends Model implements Loggable, OwnableInterface
*/
public function entity(): MorphTo
{
return $this->morphTo('commentable');
// We specifically define null here to avoid the different name (commentable)
// being used by Laravel eager loading instead of the method name, which it was doing
// in some scenarios like when deserialized when going through the queue system.
// So we instead specify the type and id column names to use.
// Related to:
// https://github.com/laravel/framework/pull/24815
// https://github.com/laravel/framework/issues/27342
// https://github.com/laravel/framework/issues/47953
// (and probably more)
// Ultimately, we could just align the method name to 'commentable' but that would be a potential
// breaking change and not really worthwhile in a patch due to the risk of creating extra problems.
return $this->morphTo(null, 'commentable_type', 'commentable_id');
}
/**
@@ -70,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

View File

@@ -0,0 +1,20 @@
<?php
namespace BookStack\Activity\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property string $mentionable_type
* @property int $mentionable_id
* @property int $from_user_id
* @property int $to_user_id
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class MentionHistory extends Model
{
protected $table = 'mention_history';
}

View File

@@ -20,6 +20,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
{
$users = User::query()->whereIn('id', array_unique($userIds))->get();
/** @var User $user */
foreach ($users as $user) {
// Prevent sending to the user that initiated the activity
if ($user->id === $initiator->id) {

View File

@@ -0,0 +1,85 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\MentionHistory;
use BookStack\Activity\Notifications\Messages\CommentMentionNotification;
use BookStack\Activity\Tools\MentionParser;
use BookStack\Entities\Models\Page;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
class CommentMentionNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
if (!($detail instanceof Comment) || !($detail->entity instanceof Page)) {
throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page");
}
/** @var Page $page */
$page = $detail->entity;
$parser = new MentionParser();
$mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html);
$realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get();
$receivingNotifications = $realMentionedUsers->filter(function (User $user) {
$prefs = new UserNotificationPreferences($user);
return $prefs->notifyOnCommentMentions();
});
$receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray();
$userMentionsToLog = $realMentionedUsers;
// When an edit, we check our history to see if we've already notified the user about this comment before
// so that we can filter them out to avoid double notifications.
if ($activity->type === ActivityType::COMMENT_UPDATE) {
$previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail);
$receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds));
$userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) {
return !in_array($user->id, $previouslyNotifiedUserIds);
});
}
$this->logMentions($userMentionsToLog, $detail, $user);
$this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page);
}
/**
* @param Collection<User> $mentionedUsers
*/
protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void
{
$mentions = [];
$now = Carbon::now();
foreach ($mentionedUsers as $mentionedUser) {
$mentions[] = [
'mentionable_type' => $comment->getMorphClass(),
'mentionable_id' => $comment->id,
'from_user_id' => $fromUser->id,
'to_user_id' => $mentionedUser->id,
'created_at' => $now,
'updated_at' => $now,
];
}
MentionHistory::query()->insert($mentions);
}
protected function getPreviouslyNotifiedUserIds(Comment $comment): array
{
return MentionHistory::query()
->where('mentionable_id', $comment->id)
->where('mentionable_type', $comment->getMorphClass())
->pluck('to_user_id')
->toArray();
}
}

View File

@@ -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)

View File

@@ -0,0 +1,37 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class CommentMentionNotification extends BaseActivityNotification
{
public function toMail(User $notifiable): MailMessage
{
/** @var Comment $comment */
$comment = $this->detail;
/** @var Page $page */
$page = $comment->entity;
$locale = $notifiable->getLocale();
$listLines = array_filter([
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
$locale->trans('notifications.detail_commenter') => $this->user->name,
$locale->trans('notifications.detail_comment') => $comment->getPlainText(),
]);
return $this->newMailMessage($locale)
->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()]))
->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine($listLines))
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
->line($this->buildReasonFooterLine($locale));
}
}

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler;
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
@@ -14,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();
@@ -34,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;
}
}
@@ -48,5 +49,7 @@ class NotificationManager
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class);
}
}

View File

@@ -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;
}
/**

View File

@@ -0,0 +1,28 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Util\HtmlDocument;
use DOMElement;
class MentionParser
{
public function parseUserIdsFromHtml(string $html): array
{
$doc = new HtmlDocument($html);
$ids = [];
$mentionLinks = $doc->queryXPath('//a[@data-mention-user-id]');
foreach ($mentionLinks as $link) {
if ($link instanceof DOMElement) {
$id = intval($link->getAttribute('data-mention-user-id'));
if ($id > 0) {
$ids[] = $id;
}
}
}
return array_values(array_unique($ids));
}
}

View File

@@ -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,
];
});

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -83,7 +83,7 @@ class HomeController extends Controller
if ($homepageOption === 'bookshelves') {
$shelves = $this->queries->shelves->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(18);
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
$data = array_merge($commonData, ['shelves' => $shelves]);
return view('home.shelves', $data);
@@ -92,7 +92,7 @@ class HomeController extends Controller
if ($homepageOption === 'books') {
$books = $this->queries->books->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(18);
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
$data = array_merge($commonData, ['books' => $books]);
return view('home.books', $data);

View File

@@ -3,6 +3,7 @@
namespace BookStack\App\Providers;
use BookStack\Access\SocialDriverManager;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Tools\ActivityLogger;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
@@ -64,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);
@@ -73,6 +81,7 @@ class AppServiceProvider extends ServiceProvider
'book' => Book::class,
'chapter' => Chapter::class,
'page' => Page::class,
'comment' => Comment::class,
]);
}
}

View File

@@ -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);
}
}

View File

@@ -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');

View File

@@ -5,11 +5,9 @@ namespace BookStack\App;
/**
* Assigned to models that can have slugs.
* Must have the below properties.
*
* @property string $slug
*/
interface SluggableInterface
{
/**
* Regenerate the slug for this model.
*/
public function refreshSlug(): string;
}

View File

@@ -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
{

View File

@@ -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

View File

@@ -81,7 +81,8 @@ return [
'strict' => false,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
// @phpstan-ignore class.notFound
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],

View File

@@ -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.

View File

@@ -41,6 +41,7 @@ return [
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
'notifications#comment-mentions' => true,
],
];

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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']);

View 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();
}
}
}

View File

@@ -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))

View File

@@ -8,6 +8,7 @@ use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
@@ -31,6 +32,7 @@ class BookController extends Controller
protected ShelfContext $shelfContext,
protected BookRepo $bookRepo,
protected BookQueries $queries,
protected EntityQueries $entityQueries,
protected BookshelfQueries $shelfQueries,
protected ReferenceFetcher $referenceFetcher,
) {
@@ -50,7 +52,7 @@ class BookController extends Controller
$books = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(18);
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
$popular = $this->queries->popularForList()->take(4)->get();
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
@@ -127,13 +129,22 @@ class BookController extends Controller
*/
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$book = $this->queries->findVisibleBySlugOrFail($slug);
try {
$book = $this->queries->findVisibleBySlugOrFail($slug);
} catch (NotFoundException $exception) {
$book = $this->entityQueries->findVisibleByOldSlugs('book', $slug);
if (is_null($book)) {
throw $exception;
}
return redirect($book->getUrl());
}
$bookChildren = (new BookContents($book))->getTree(true);
$bookParentShelves = $book->shelves()->scopes('visible')->get();
View::incrementFor($book);
if ($request->has('shelf')) {
$this->shelfContext->setShelfContext(intval($request->get('shelf')));
$this->shelfContext->setShelfContext(intval($request->input('shelf')));
}
$this->setPageTitle($book->getShortName());
@@ -213,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');
}
@@ -247,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'));

View File

@@ -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);

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityQueries;
use BookStack\Activity\Models\View;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException;
@@ -23,6 +24,7 @@ class BookshelfController extends Controller
public function __construct(
protected BookshelfRepo $shelfRepo,
protected BookshelfQueries $queries,
protected EntityQueries $entityQueries,
protected BookQueries $bookQueries,
protected ShelfContext $shelfContext,
protected ReferenceFetcher $referenceFetcher,
@@ -43,7 +45,7 @@ class BookshelfController extends Controller
$shelves = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(18);
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
$popular = $this->queries->popularForList()->get();
$new = $this->queries->visibleForList()
@@ -92,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());
@@ -105,7 +107,16 @@ class BookshelfController extends Controller
*/
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
try {
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
} catch (NotFoundException $exception) {
$shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug);
if (is_null($shelf)) {
throw $exception;
}
return redirect($shelf->getUrl());
}
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
@@ -185,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());

View File

@@ -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);

View File

@@ -77,7 +77,15 @@ class ChapterController extends Controller
*/
public function show(string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
try {
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
} catch (NotFoundException $exception) {
$chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug);
if (is_null($chapter)) {
throw $exception;
}
return redirect($chapter->getUrl());
}
$sidebarTree = (new BookContents($chapter->book))->getTree();
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
@@ -195,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());
}
@@ -240,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) {
@@ -251,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'));

View File

@@ -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())) {

View File

@@ -17,11 +17,12 @@ use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditActivity;
use BookStack\Entities\Tools\PageEditorData;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
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;
@@ -87,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'));
@@ -140,9 +141,7 @@ class PageController extends Controller
try {
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
} catch (NotFoundException $e) {
$revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
$page = $revision->page ?? null;
$page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug);
if (is_null($page)) {
throw $e;
}
@@ -176,7 +175,7 @@ class PageController extends Controller
}
/**
* Get page from an ajax request.
* Get a page from an ajax request.
*
* @throws NotFoundException
*/
@@ -186,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);
}
@@ -405,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());
}
@@ -450,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) {
@@ -461,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'));

View File

@@ -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);

View File

@@ -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()

View File

@@ -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

View File

@@ -2,7 +2,6 @@
namespace BookStack\Entities\Models;
use BookStack\References\ReferenceUpdater;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
@@ -17,34 +16,10 @@ abstract class BookChild extends Entity
{
/**
* Get the book this page sits in.
* @return BelongsTo<Book, $this>
*/
public function book(): BelongsTo
{
return $this->belongsTo(Book::class)->withTrashed();
}
/**
* Change the book that this entity belongs to.
*/
public function changeBook(int $newBookId): self
{
$oldUrl = $this->getUrl();
$this->book_id = $newBookId;
$this->unsetRelation('book');
$this->refreshSlug();
$this->save();
if ($oldUrl !== $this->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
}
// Update all child pages if a chapter
if ($this instanceof Chapter) {
foreach ($this->pages()->withTrashed()->get() as $page) {
$page->changeBook($newBookId);
}
}
return $this;
}
}

View File

@@ -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'];
/**

View File

@@ -13,7 +13,6 @@ use BookStack\Activity\Models\Viewable;
use BookStack\Activity\Models\Watch;
use BookStack\App\Model;
use BookStack\App\SluggableInterface;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Permissions\JointPermissionBuilder;
use BookStack\Permissions\Models\EntityPermission;
use BookStack\Permissions\Models\JointPermission;
@@ -405,16 +404,6 @@ abstract class Entity extends Model implements
app()->make(SearchIndex::class)->indexEntity(clone $this);
}
/**
* {@inheritdoc}
*/
public function refreshSlug(): string
{
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
return $this->slug;
}
/**
* {@inheritdoc}
*/
@@ -441,6 +430,14 @@ abstract class Entity extends Model implements
return $this->morphMany(Watch::class, 'watchable');
}
/**
* Get the related slug history for this entity.
*/
public function slugHistory(): MorphMany
{
return $this->morphMany(SlugHistory::class, 'sluggable');
}
/**
* {@inheritdoc}
*/
@@ -482,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}"),
};
}
}

View File

@@ -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
@@ -124,6 +124,14 @@ class Page extends BookChild
return url('/' . implode('/', $parts));
}
/**
* Get the ID-based permalink for this page.
*/
public function getPermalink(): string
{
return url("/link/{$this->id}");
}
/**
* Get this page for JSON display.
*/

View File

@@ -0,0 +1,28 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property int $sluggable_id
* @property string $sluggable_type
* @property string $slug
* @property ?string $parent_slug
*/
class SlugHistory extends Model
{
use HasFactory;
protected $table = 'slug_history';
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'sluggable_id')
->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type');
}
}

View File

@@ -4,6 +4,7 @@ namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityTable;
use BookStack\Entities\Tools\SlugHistory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\JoinClause;
@@ -18,6 +19,7 @@ class EntityQueries
public ChapterQueries $chapters,
public PageQueries $pages,
public PageRevisionQueries $revisions,
protected SlugHistory $slugHistory,
) {
}
@@ -31,9 +33,30 @@ class EntityQueries
$explodedId = explode(':', $identifier);
$entityType = $explodedId[0];
$entityId = intval($explodedId[1]);
$queries = $this->getQueriesForType($entityType);
return $queries->findVisibleById($entityId);
return $this->findVisibleById($entityType, $entityId);
}
/**
* Find an entity by its ID.
*/
public function findVisibleById(string $type, int $id): ?Entity
{
$queries = $this->getQueriesForType($type);
return $queries->findVisibleById($id);
}
/**
* Find an entity by looking up old slugs in the slug history.
*/
public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity
{
$id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug);
if ($id === null) {
return null;
}
return $this->findVisibleById($type, $id);
}
/**

View File

@@ -8,12 +8,15 @@ use BookStack\Entities\Models\HasCoverInterface;
use BookStack\Entities\Models\HasDescriptionInterface;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Entities\Tools\SlugHistory;
use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore;
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
@@ -25,6 +28,8 @@ class BaseRepo
protected ReferenceStore $referenceStore,
protected PageQueries $pageQueries,
protected BookSorter $bookSorter,
protected SlugGenerator $slugGenerator,
protected SlugHistory $slugHistory,
) {
}
@@ -43,7 +48,7 @@ class BaseRepo
'updated_by' => user()->id,
'owned_by' => user()->id,
]);
$entity->refreshSlug();
$this->refreshSlug($entity);
if ($entity instanceof HasDescriptionInterface) {
$this->updateDescription($entity, $input);
@@ -78,7 +83,7 @@ class BaseRepo
$entity->updated_by = user()->id;
if ($entity->isDirty('name') || empty($entity->slug)) {
$entity->refreshSlug();
$this->refreshSlug($entity);
}
if ($entity instanceof HasDescriptionInterface) {
@@ -147,12 +152,22 @@ 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']);
}
}
/**
* Refresh the slug for the given entity.
*/
public function refreshSlug(Entity $entity): void
{
$this->slugHistory->recordForEntity($entity);
$this->slugGenerator->regenerateForEntity($entity);
}
}

View File

@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\ParentChanger;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException;
@@ -21,6 +22,7 @@ class ChapterRepo
protected BaseRepo $baseRepo,
protected EntityQueries $entityQueries,
protected TrashCan $trashCan,
protected ParentChanger $parentChanger,
) {
}
@@ -97,7 +99,7 @@ class ChapterRepo
}
return (new DatabaseTransaction(function () use ($chapter, $parent) {
$chapter = $chapter->changeBook($parent->id);
$this->parentChanger->changeBook($chapter, $parent->id);
$chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);

View File

@@ -12,6 +12,7 @@ use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditorType;
use BookStack\Entities\Tools\ParentChanger;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException;
@@ -31,6 +32,7 @@ class PageRepo
protected ReferenceStore $referenceStore,
protected ReferenceUpdater $referenceUpdater,
protected TrashCan $trashCan,
protected ParentChanger $parentChanger,
) {
}
@@ -58,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,
@@ -242,7 +244,7 @@ class PageRepo
}
$page->updated_by = user()->id;
$page->refreshSlug();
$this->baseRepo->refreshSlug($page);
$page->save();
$page->indexForSearch();
$this->referenceStore->updateForEntity($page);
@@ -284,7 +286,7 @@ class PageRepo
return (new DatabaseTransaction(function () use ($page, $parent) {
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
$page = $page->changeBook($newBookId);
$this->parentChanger->changeBook($page, $newBookId);
$page->rebuildPermissions();
Activity::add(ActivityType::PAGE_MOVE, $page);

View File

@@ -13,30 +13,47 @@ use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceChangeContext;
use BookStack\References\ReferenceUpdater;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
use Illuminate\Http\UploadedFile;
class Cloner
{
protected ReferenceChangeContext $referenceChangeContext;
public function __construct(
protected PageRepo $pageRepo,
protected ChapterRepo $chapterRepo,
protected BookRepo $bookRepo,
protected ImageService $imageService,
protected ReferenceUpdater $referenceUpdater,
) {
$this->referenceChangeContext = new ReferenceChangeContext();
}
/**
* Clone the given page into the given parent using the provided name.
*/
public function clonePage(Page $original, Entity $parent, string $newName): Page
{
$context = $this->newReferenceChangeContext();
$page = $this->createPageClone($original, $parent, $newName);
$this->referenceUpdater->changeReferencesUsingContext($context);
return $page;
}
protected function createPageClone(Page $original, Entity $parent, string $newName): Page
{
$copyPage = $this->pageRepo->getNewDraftPage($parent);
$pageData = $this->entityToInputData($original);
$pageData['name'] = $newName;
return $this->pageRepo->publishDraft($copyPage, $pageData);
$newPage = $this->pageRepo->publishDraft($copyPage, $pageData);
$this->referenceChangeContext->add($original, $newPage);
return $newPage;
}
/**
@@ -44,6 +61,14 @@ class Cloner
* Clones all child pages.
*/
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
{
$context = $this->newReferenceChangeContext();
$chapter = $this->createChapterClone($original, $parent, $newName);
$this->referenceUpdater->changeReferencesUsingContext($context);
return $chapter;
}
protected function createChapterClone(Chapter $original, Book $parent, string $newName): Chapter
{
$chapterDetails = $this->entityToInputData($original);
$chapterDetails['name'] = $newName;
@@ -53,10 +78,12 @@ class Cloner
if (userCan(Permission::PageCreate, $copyChapter)) {
/** @var Page $page */
foreach ($original->getVisiblePages() as $page) {
$this->clonePage($page, $copyChapter, $page->name);
$this->createPageClone($page, $copyChapter, $page->name);
}
}
$this->referenceChangeContext->add($original, $copyChapter);
return $copyChapter;
}
@@ -65,6 +92,14 @@ class Cloner
* Clones all child chapters and pages.
*/
public function cloneBook(Book $original, string $newName): Book
{
$context = $this->newReferenceChangeContext();
$book = $this->createBookClone($original, $newName);
$this->referenceUpdater->changeReferencesUsingContext($context);
return $book;
}
protected function createBookClone(Book $original, string $newName): Book
{
$bookDetails = $this->entityToInputData($original);
$bookDetails['name'] = $newName;
@@ -76,11 +111,11 @@ class Cloner
$directChildren = $original->getDirectVisibleChildren();
foreach ($directChildren as $child) {
if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) {
$this->cloneChapter($child, $copyBook, $child->name);
$this->createChapterClone($child, $copyBook, $child->name);
}
if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) {
$this->clonePage($child, $copyBook, $child->name);
$this->createPageClone($child, $copyBook, $child->name);
}
}
@@ -92,6 +127,8 @@ class Cloner
}
}
$this->referenceChangeContext->add($original, $copyBook);
return $copyBook;
}
@@ -155,4 +192,10 @@ class Cloner
return $tags;
}
protected function newReferenceChangeContext(): ReferenceChangeContext
{
$this->referenceChangeContext = new ReferenceChangeContext();
return $this->referenceChangeContext;
}
}

View File

@@ -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

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