Compare commits

...

298 Commits

Author SHA1 Message Date
Dan Brown
26aadffb20 Updated version and assets for release v24.10 2024-10-09 10:48:34 +01:00
Dan Brown
a5f48e3202 Merge branch 'development' into release 2024-10-09 10:46:07 +01:00
Dan Brown
a58102d6ef Attribution: Updated translator & license files before v24.10 2024-10-09 10:26:07 +01:00
Dan Brown
65453bd94e Updated translations with latest Crowdin changes (#5188) 2024-10-09 10:21:55 +01:00
Dan Brown
d22413b931 JS: Converted/updated translation code to TS, fixed some comment counts
- Migrated translation service to TS, stripping a lot of now unused code
  along the way.
- Added test to cover translation service.
- Fixed some comment count issues, where it was not showing correct
  value. or updating, on comment create or delete.
2024-10-07 22:55:10 +01:00
Dan Brown
8b9bcc1768 Search: Fixed last commented filter when using table prefixes 2024-10-05 15:20:04 +01:00
Dan Brown
51287d545b Searching: Fixed some form search issues
- Form was not retaining certain filters
- Form request handling of entity type set wrong filter name
Added test to cover.
2024-10-05 14:49:30 +01:00
Dan Brown
c314a60a16 WYSIWYG: Code & table fixes
- Fixed new code block insertion to remove selection area instead of
  just adding after.
- Added default table column widths to not be collapsed
- Updated table dom export to not duplicate colgroups.
2024-10-05 12:42:47 +01:00
Dan Brown
9b2520aa0c WYSIWYG: Fixed list indenting selection & display bugs
- Fixed selection breaking on multiple indent changes
- Fixed multi-indent showing numbers on empty child list until the nodes
  are fully re-rendered.
2024-10-04 15:11:09 +01:00
Dan Brown
346b88ae43 JS: Converted a few extra services to TS 2024-10-04 14:36:20 +01:00
Dan Brown
2766c76491 TinyMCE: Updated version from 6.8.3 to 6.8.4 2024-10-04 12:46:22 +01:00
Dan Brown
be6529d0a1 New WYSIWYG: Added mac shortcut support 2024-10-04 12:41:13 +01:00
Dan Brown
b1a3ea1aa4 Languages: Enabled Welsh option 2024-10-04 11:02:17 +01:00
Dan Brown
6646dcc24d Merge pull request #5239 from BookStackApp/search_negation
Search term negation
2024-10-03 19:52:06 +01:00
Dan Brown
966ff91386 Search: Prevented negated terms filling in UI inputs
Added test to cover.
2024-10-03 19:40:11 +01:00
Dan Brown
cd84d08157 Search: Added exact/filter/tag term negation support 2024-10-03 19:27:03 +01:00
Dan Brown
93c677a6a9 Searching: Added negation support to UI and term handling
Updated/added tests to cover.
Support for actual search queries still remains.
2024-10-03 15:59:50 +01:00
Dan Brown
177cfd72bf Search: Added structure for search term inputs
Sets things up to allow more complex terms ready to handle negation.
2024-10-02 17:31:45 +01:00
Dan Brown
34ade50181 Base layout: Changed main app script to be module loaded
Prevents polluting global scope with variables since we're using the
module format bundler in esbuild.
Also cleaned up unused yields.
Fixed bad reference in our tinymce fixes.

For #5232
2024-10-01 10:37:31 +01:00
Dan Brown
e65655594f Merge branch 'feature/opensearch' into development 2024-09-30 17:21:51 +01:00
Dan Brown
514db60617 Tests: Categorised up meta tests
Extracted robots.txt tests into its own file to fit into new folder.
Also tweaked open search tests a tad to specifically check long app
names.
2024-09-30 17:07:53 +01:00
Dan Brown
8bc6e75319 Code Blocks: Added SAS and R language options
For #5206
2024-09-30 16:47:55 +01:00
Maximilian Walter
2f74cfb42c Add test for OpenSearch endpoint 2024-09-30 17:45:20 +02:00
Maximilian Walter
1302e3c959 Add missing XML declaration to OpenSearch endpoint 2024-09-30 17:45:20 +02:00
Maximilian Walter
a5b031f906 Translatable description for OpenSearch XML 2024-09-30 17:45:20 +02:00
Dan Brown
f583354748 Maintenance: Removed stray dd from last commit 2024-09-29 16:50:48 +01:00
Dan Brown
d12e8ec923 Users: Improved user response for failed invite sending
Added specific handling to show relevant error message when user
creation fails due to invite sending errors, while also returning user
to the form with previous input.
Includes test to cover.

For #5195
2024-09-29 16:41:18 +01:00
Dan Brown
89f84c9a95 Pages: Updated editor field to always be set
- Migration for setting on existing pages
- Added test to cover simple new page scenario

For #5117
2024-09-29 14:36:41 +01:00
Dan Brown
6103a22feb Exports: Made pdf command timeout configurable
Added test to cover.
For #5119
2024-09-27 16:33:58 +01:00
Dan Brown
42264f402d CSS: Fixed floating search icon on mobile
Also updated styles to use logical elements instead of conditional rules
for altered search boxes.
Related to #2504
2024-09-27 16:02:13 +01:00
Dan Brown
abda9bc00a PHP Dependancies: Updated packages pending major version changes
Closes #5222
2024-09-27 14:21:12 +01:00
Dan Brown
eec639d84e Maintenance: Fixed js lint and SCSS build warnings 2024-09-27 13:57:39 +01:00
Dan Brown
56b9107c6b Dependancies: Updated php & JS deps, updated license lists
Fixed issue now picked up by newer TS version
2024-09-27 12:29:19 +01:00
Dan Brown
b35b62d59f Merge branch 'lexical' into development 2024-09-27 12:04:01 +01:00
Dan Brown
1b9310e766 Meta: Added lexical licensing info and added TS/JS CI testing 2024-09-27 10:45:48 +01:00
Dan Brown
a62d8381be Lexical: Updated toolbar & text node exporting
- Updated toolbar to match existing editor, including dynamic RTL/LTR
  controls.
- Updated text node handling to not include spans and extra classes when
  not needed. Added & update tests to cover.
2024-09-23 17:36:16 +01:00
Dan Brown
8b32e6c15a Page Editors: Added switching/options for new lexical editor 2024-09-22 20:06:55 +01:00
Dan Brown
c8ccb2bac7 Lexical: Range of fixes
- Prevented ui shortcuts running in editor
- Added form modal closing on submit
- Fixed ability to escape lists via enter on empty last item
2024-09-22 16:15:02 +01:00
Dan Brown
ef3de1050f Lexical: Added UI translation support 2024-09-22 12:29:06 +01:00
Dan Brown
2add15bd72 Lexical: Added direction support to extra blocks
Also removed duplicated dir functionality that remained in core.
2024-09-22 12:07:24 +01:00
Dan Brown
e6edd9340e Lexical: Added alignment detoggle, fixed inital focus area 2024-09-21 17:02:54 +01:00
Dan Brown
654a7a5d03 Lexical: Removed reconciler level direction handling
- Updated tests to consider changes
2024-09-21 13:00:16 +01:00
Dan Brown
dba8ab947f Lexical: Finished conversion/update of test files 2024-09-20 15:31:19 +01:00
Dan Brown
787e06e3d8 Lexical: Adapted a range of further existing tests 2024-09-20 13:05:29 +01:00
Dan Brown
ccd486f2a9 Lexical: Got a range of Editor tests working 2024-09-18 17:31:51 +01:00
Dan Brown
22d078b47f Lexical: Imported core lexical libs
Imported at 0.17.1, Modified to work in-app.
Added & configured test dependancies.
Tests need to be altered to avoid using non-included deps including
react dependancies.
2024-09-18 13:43:39 +01:00
Dan Brown
03490d6597 Lexical: Added RTL/LTR actions
Kinda useless though due to Lexical reconciler :(
2024-09-16 12:29:46 +01:00
Dan Brown
5f46d71af0 Lexical: Fixed a range of issues in RTL mode 2024-09-15 16:10:46 +01:00
Maximilian Walter
4f890c431c Limit short-name for OpenSearch XML to 16 characters
The specification does not allow more than 16 characters.
2024-09-14 15:31:56 +02:00
Maximilian Walter
c110a97d8a Remove unofficial method-attribute from OpenSearch-XML 2024-09-14 15:24:42 +02:00
Dan Brown
6872eb802c Lexical: Altered keyboard handling to indicant handled state 2024-09-13 16:05:55 +01:00
Dan Brown
662110c269 Lexical: Custom list nesting support
Added list nesting support to allow li > ul style nesting which lexical
didn't do by default.
Adds tab handling for inset/outset controls.
Will be a range of edge-case bugs to squash during testing.
2024-09-13 15:50:42 +01:00
Dan Brown
5083188ed8 Lexical: Added block indenting capability
Needed a custom implementation due to hardcoded defaults for Lexical
default indenting.
2024-09-10 15:55:46 +01:00
Dan Brown
2036438203 Lexical: Added single node enter handling
Also updated media to be an inline element to align with old editor
behaviour.
2024-09-10 12:14:26 +01:00
Maximilian Walter
476c2be5a6 Add XML for OpenSearch 2024-09-09 22:54:33 +02:00
Dan Brown
ced66f1671 Lexical: Added single node backspace/delete support 2024-09-09 18:33:54 +01:00
Dan Brown
fb49371c6b Lexical: Refined editor UI
- Cleaned up dropdown lists to look integrated
- Added icons for color picker clear and menu list items
2024-09-09 14:06:41 +01:00
Dan Brown
fd07aa0f05 Lexical: Further fixes
- Improved node resizer positioning to be more accurate
- Fixed drop handling not running within editor margin space
- Made media dom update smarter to reduce reloads
- Fixed media alignment, broken due to added wrapper
2024-09-09 12:28:01 +01:00
Dan Brown
16518a4f89 Lexical: Range of bug fixes, Updated lexical version
- Updated selection change detection to be more accurate
- Added UI refresh for extra actions
- Fixed remove link deleting contents
2024-09-08 15:54:59 +01:00
Dan Brown
bed2c29a33 Lexical: Added media resize support via drag handles 2024-09-08 13:37:13 +01:00
Dan Brown
e5b6d28bca Lexical: Revamped image node resize method
Changed from using a decorator to using a helper that watches for image
selections to then display a resize helper.
Also changes resizer to use a ghost and apply changes on end instead of
continuosly during resize.
2024-09-07 18:39:58 +01:00
Dan Brown
1c9afcb84e Lexical: Added some level of img/media alignment 2024-09-06 14:07:10 +01:00
Dan Brown
b0dda6e6a7 Updated version and assets for release v24.05.4 2024-08-29 16:04:51 +01:00
Dan Brown
d4025d95e7 Merge branch 'development' into release 2024-08-29 16:04:37 +01:00
Dan Brown
3a058a6e34 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2024-08-29 15:28:52 +01:00
Dan Brown
aac7d564c8 Updated translations with latest Crowdin changes (#5118) 2024-08-29 15:08:27 +01:00
Dan Brown
9aa3442a17 API: Fixed lacking permission enforcement on book contents 2024-08-29 14:43:21 +01:00
Dan Brown
c68d154f0f LDAP: Updated tests for recursive group changes 2024-08-28 21:16:18 +01:00
Dan Brown
1b4ed69f41 LDAP: Updated recursive group search to query by DN
Added test to cover, added pre-change.
Need to test post-changes and fix tests.
2024-08-28 15:39:05 +01:00
Dan Brown
8cef998f49 RTL: Fixed lacking task list RTL support
Added with fallback to old LTR styles.
For #5134
2024-08-27 14:13:33 +01:00
Dan Brown
90d1223acd Styles: Added max-width for iframes in content
For #5130
2024-08-27 13:32:16 +01:00
Dan Brown
1f2506221a API: Updated docs with consistent types, fixed users response example
For #5178 and #5183
2024-08-27 12:23:36 +01:00
Dan Brown
9f68ca5358 Dependancies: Updated PHP and JS packages 2024-08-26 11:49:02 +01:00
Dan Brown
1ebb0f8c93 Lexical: Added table column cut/copy/paste support 2024-08-22 13:28:30 +01:00
Dan Brown
8a13a9df80 Lexical: Improved table row copy/paste
Added safeguarding/matching of source/target sizes to prevent broken
tables.
2024-08-22 10:08:08 +01:00
Dan Brown
ddf5f2543c Lexical: Added drop/paste image handling 2024-08-21 12:59:45 +01:00
Dan Brown
dbb2fe3e59 Lexical: Finished off baseline shortcut implementation 2024-08-20 14:54:53 +01:00
Dan Brown
aa1fac62d5 Lexical: Started adding editor shortcuts 2024-08-20 13:07:33 +01:00
Dan Brown
111a313d51 Lexical: Added custom alignment handling for blocks
To align with pre-existing use of alignment classes.
2024-08-18 16:51:08 +01:00
Dan Brown
0039f893cc Lexical: Integrated diagram manager, added menu split button 2024-08-17 10:48:34 +01:00
Dan Brown
ad6b26ba97 Lexical: Added basic URL field header option list
May show bad option label names on chrome/safari.
This was an easy first pass without loads of extra custom UI since we're
using native datalists.
2024-08-16 12:29:40 +01:00
Dan Brown
1ef4044419 Lexical: Connected link selector to link form 2024-08-16 11:22:12 +01:00
Dan Brown
accf2565a0 Lexical: Integrated image manager to image button/form 2024-08-13 19:36:18 +01:00
Dan Brown
ec965f28c0 Lexical: Added id support for all main block types 2024-08-11 16:08:51 +01:00
Dan Brown
ebf95f637a Lexical: Wired table properties, and other buttons 2024-08-10 13:14:55 +01:00
Dan Brown
abbfd42a6c Lexical: Kinda made row copy/paste work 2024-08-09 21:58:45 +01:00
Dan Brown
db4208a7eb Lexical: Linked row properties form up 2024-08-09 12:42:04 +01:00
Dan Brown
da54e1d87c Lexical: Added cell width fetching, Created custom row node 2024-08-09 11:24:25 +01:00
Dan Brown
e8532ef4de Lexical: Added merge cell logic 2024-08-07 20:32:54 +01:00
Dan Brown
fa6d66db49 Readme: Updated sponsor image links to use website 2024-08-07 10:53:20 +01:00
Alexander Wilms
6604e7365f Update sponsor image URLs in readme 2024-08-06 23:30:05 +00:00
Dan Brown
fcc1c2968d Lexical: Added table cell node import logic 2024-08-06 09:36:37 +01:00
Dan Brown
b3d3b14f79 Lexical: Finished off core cell properties functionality 2024-08-05 18:49:17 +01:00
Dan Brown
8939f310db Lexical: Started linking up cell properties form 2024-08-05 15:08:52 +01:00
Dan Brown
efec752985 Lexical: Split helpers to utils, refactored files 2024-08-03 18:14:01 +01:00
Dan Brown
e94ad78ea7 Lexical: Completed out table menu elements, logic pending 2024-08-03 18:01:54 +01:00
Dan Brown
a27a325af7 Lexical: Started on table actions
Started building table cell form/actions
2024-08-02 15:28:54 +01:00
Dan Brown
6b06d490c5 Lexical: Started table menu options
Updated UI elements to handle new scenarios needed in more complex table
menu
2024-08-02 11:16:54 +01:00
Dan Brown
13f8f39dd5 Lexical: Updated task list to use/support old format 2024-07-30 14:42:19 +01:00
Dan Brown
fe05cff64f Lexical: Linked up change/draft management 2024-07-29 21:43:20 +01:00
Dan Brown
d86837ac07 Lexical: Got working with attachment insert/drop 2024-07-29 21:14:42 +01:00
Dan Brown
9a7edc6e52 Lexical: Started drop handling, handled templates 2024-07-29 15:27:41 +01:00
Dan Brown
ce8c9dd079 Lexical: Added form complex/tab ui support 2024-07-28 12:48:58 +01:00
Dan Brown
c8f6b7e0d6 Lexical: Got media node core work & form done 2024-07-27 17:25:30 +01:00
Dan Brown
f284d31861 Lexical: Started media node support 2024-07-25 16:25:08 +01:00
Dan Brown
76b0d2d5d8 Lexical: Added common events support 2024-07-23 15:35:18 +01:00
Dan Brown
2cab778f19 Lexical: Improved table resize bars
Added scoll & page resize handling.
Added cropping/limiting to edit area.
2024-07-23 12:45:58 +01:00
Dan Brown
c31f8eb2e0 Readme: Added route4me sponsorship 2024-07-22 16:51:56 +01:00
Dan Brown
b618287585 Lexical: Added table toolbar, organised button code 2024-07-21 15:11:24 +01:00
Dan Brown
63f4b42453 Lexical: Added toolbar scroll/resize handling
Also added smarter above/below positioning to respond if toolbar would
be off the bottom of the editor, and added hide/show when they'd go
outside editor scroll bounds.
2024-07-19 18:12:51 +01:00
Dan Brown
c7c0df0964 Lexical: Finished up core drawing insert/editing
Added new options that sits on the context, for things needed but not
for the core editor, which are defined out of the editor (drawio URL,
error message text, pageId etc...)
2024-07-19 12:09:41 +01:00
Dan Brown
fb87fb5750 JS: Converted http service to ts 2024-07-18 15:13:14 +01:00
Dan Brown
634b0aaa07 Lexical: Started converting drawio to TS
Converted events service to TS as part of this.
2024-07-18 11:19:11 +01:00
Dan Brown
5002a89754 Lexical: Standardised helper function format 2024-07-17 16:45:57 +01:00
Dan Brown
b367490edc Lexical: Added list support, started todo 2024-07-17 16:38:20 +01:00
Dan Brown
e145f21512 Dev compose: Set image versions, removed unsupported mysql flag
Quick local test performed, ran a working instance.
For #5124
2024-07-17 11:13:39 +01:00
Dan Brown
ea4c50c2c2 Lexical: Added code block selection & edit features
Also added extra lifecycle handling for decorators to things can be
properly cleaned up after node destruction.
2024-07-16 16:36:08 +01:00
Dan Brown
d6021f4d22 Updated version and assets for release v24.05.3 2024-07-14 17:14:21 +01:00
Dan Brown
b9a3290731 Merge branch 'development' into release 2024-07-14 17:13:10 +01:00
Dan Brown
47ac0d5c3e Updated translator & dependency attribution before release v24.05.3 2024-07-14 17:09:41 +01:00
Dan Brown
75f225d6dc Updated translations with latest Crowdin changes (#5065) 2024-07-14 16:39:50 +01:00
Dan Brown
adb7bf7016 Codemirror: Enabled non-standard self-closing tags
For #5078
2024-07-14 16:36:36 +01:00
Dan Brown
897bb338f9 CSP: Updated handling of drawio URL to consider port
Previously if a custom port was used in the DRAWIO option it would not
be considered in the CSP handling, which would block loading.

Added test to cover.
For #5107
2024-07-14 16:06:18 +01:00
Dan Brown
767699a066 OIDC: Fixed incorrect detection of group detail population
An empty (but valid formed) groups list provided via the OIDC ID token
would be considered as a lacking detail, and therefore trigger a lookup
to the userinfo endpoint in an attempt to get that information.

This fixes this to properly distinguish between not-provided and empty
state, to avoid userinfo where provided as valid but empty.

Includes test to cover.
For #5101
2024-07-14 14:21:16 +01:00
Dan Brown
7161f22706 Dependancies: Updated composer & npm deps 2024-07-14 13:55:46 +01:00
Dan Brown
ddec8097b7 Merge pull request #5096 from DanielGordonIT/normalize-file-extensions
Wraps file extension comparison components in strtolower()
2024-07-14 13:51:55 +01:00
Dan Brown
95c3cc5c00 Styles: Improved callout RTL support
Will now adapt using logical styles where supported, will fallbacks
to old fixed LTR positioning where not supported.
For #5104
2024-07-14 12:21:07 +01:00
Dan Brown
60c53705ca Merge pull request #5069 from mueller-contria/5068-allowed_iframe_sources_in_phpunit_xml
Add ALLOWED_IFRAME_SOURCES to phpunit.xml
2024-07-14 12:06:17 +01:00
Dan Brown
51d8044a54 Lexical: Added initial form/modal styles 2024-07-09 20:49:47 +01:00
Dan Brown
ce697ab0f5 Readme: Added sponsor, removed road map section
Road map section was very much outdated and redundant so removing to
avoid confusion.
2024-07-09 14:37:29 +01:00
DanielGordonIT
ca310966b2 Actually add the test this time 2024-07-05 03:59:49 +00:00
DanielGordonIT
25f92ce584 Add test to verify different case on extensions works 2024-07-04 19:48:12 -04:00
Dan Brown
2c96af9aea Lexical: Worked on toolbar styling, got format submenu working 2024-07-04 16:16:16 +01:00
Dan Brown
04c7e680fd Lexical: Linked up saving logic of editor via interface 2024-07-04 13:09:53 +01:00
DanielGordonIT
9b0ef85f77 Wraps file extension comparison components in strtolower()
This avoids the issue where replacing file.PNG with newfile.png fails due to "PNG" not being equal to "png"
2024-07-03 15:50:25 -04:00
Dan Brown
a8f1160743 JS: Converted come common services to typescript 2024-07-03 11:00:57 +01:00
Dan Brown
feca1f0502 Lexical: Started diagram support 2024-07-03 10:28:04 +01:00
Dan Brown
d0a5a5ef37 Lexical: Linked code block to editor, added button 2024-07-02 17:34:03 +01:00
Dan Brown
97f570a4ee Lexical: Started code block node implementation 2024-07-02 14:46:30 +01:00
Dan Brown
9ebbf7ce94 Lexical: Started loading real content, Improved html loading
Added more styling/layout for buttons and main content area
2024-07-01 15:10:22 +01:00
Dan Brown
c2ecbf071f Lexical: Added tracked container, added fullscreen action
Changed how the editor is loaded in, so it now creates its own DOM, and
content is passed via creation function, to be better self-contained.
2024-07-01 10:44:23 +01:00
Dan Brown
b1c489090e Lexical: Added context toolbar placement, added link toolbar
Also added some basic context toolbar styling
2024-06-30 19:52:09 +01:00
Dan Brown
c9a03c5b01 Lexical: Added base context toolbar logic 2024-06-30 12:13:13 +01:00
Dan Brown
517c578a5f Lexical: Reorganised some logic into manager 2024-06-30 10:31:39 +01:00
Dan Brown
14837e34fb Readme: Added sponsor practinet 2024-06-28 22:28:06 +01:00
Dan Brown
f10ec3271a Lexical: Added overflow container 2024-06-27 16:28:06 +01:00
Dan Brown
4e2820d6e3 Lexical: Added horizontal rule node 2024-06-27 15:48:06 +01:00
Dan Brown
72a0e081ca Lexical: Completed initial table cell resize handle logic 2024-06-26 17:22:00 +01:00
Dan Brown
b1130cb1c3 Lexical: Linked up table resize handler (unfinished) 2024-06-26 13:52:00 +01:00
Dan Brown
59936631ec Lexical: Extracted mouse drag tracking to new helper 2024-06-25 18:33:29 +01:00
Dan Brown
3af22ce754 Lexical: Created custom table node with col width handling 2024-06-24 20:50:17 +01:00
Dan Brown
5546b8ff43 Lexical: Added more icons, made reflective text/bg color buttons 2024-06-23 15:50:41 +01:00
Dan Brown
a07092b7e6 Lexical: Updated lexical, added undo state tracking, format styles 2024-06-23 11:36:48 +01:00
Dan Brown
ac01c62e6e Lexical: Added table creator UI 2024-06-21 16:18:44 +01:00
Dan Brown
f47f7dd9d2 Lexical: Added base table support and started resize handling 2024-06-21 13:47:47 +01:00
Dan Brown
13d970c7ce Lexical: Added button icon system
With a bunch of default icons
2024-06-19 20:00:29 +01:00
Dan Brown
e2409a5fab Lexical: Added basic list button/support 2024-06-19 16:14:20 +01:00
Dan Brown
e30aae3399 Sponsors: Added Schroeck IT Consulting 2024-06-13 16:46:39 +01:00
Stefan Mueller
b81f2b52d0 Add ALLOWED_IFRAME_SOURCES to phpunit.xml
Fix for bug #5068
test_frame_src_csp_header_set fails, when .env-file has
customized ALLOWED_IFRAME_SOURCES
2024-06-13 12:41:05 +02:00
Dan Brown
9e43e03db4 Lexical: Added color picker controls 2024-06-12 19:51:42 +01:00
Dan Brown
a475cf68bf Lexical: Added clear formatting button 2024-06-12 14:24:50 +01:00
Dan Brown
e889bc680b Lexical: Added view/edit source code button/form/action 2024-06-12 14:01:36 +01:00
Dan Brown
48f235ea5a Updated version and assets for release v24.05.2 2024-06-10 11:44:06 +01:00
Dan Brown
047771b9f4 Merge branch 'development' into release 2024-06-10 11:43:05 +01:00
Dan Brown
c096b20d9c Updated translator & dependency attribution before release v24.05.2 2024-06-10 11:42:37 +01:00
Dan Brown
11a7ccc37e SAML: Set static type to pass static checks
Not totally clear if underlying code can actually return null, but
playing it safe to remain as-is for now for patch release.
2024-06-10 10:31:35 +01:00
Dan Brown
d9b9e6c0b1 Updated translations with latest Crowdin changes (#5022) 2024-06-10 10:16:34 +01:00
Dan Brown
f18d42f08e Merge pull request #5036 from bradenterpstra01/development
Fixed incorrect code shortcut reference
2024-06-09 23:23:28 +01:00
Dan Brown
4986f008b9 Merge pull request #5052 from michaelortnerit/development
Update docker-compose.yml
2024-06-09 23:20:01 +01:00
Dan Brown
a8ce199e0d Pages: Fixed unused changelog on first page publish
Included test to cover.
For #5056
2024-06-09 17:18:23 +01:00
Dan Brown
c77e8730d6 Deps: Updated php packages via composer 2024-06-09 17:03:29 +01:00
Dan Brown
3406846c82 Images: Updated GIF handling to use native methods
Changes GIF image thumbnail handling to direcly load via gd instead of
going through interventions own handling (which supports frames) since
we don't need animation for our thumbnails, and since performance issues
could arise with GIFs that have large frame counts.

For #5029
2024-06-09 17:00:58 +01:00
Dan Brown
bddc6ae66b Roles: Added max validation for role external auth id field
For #5037
2024-06-08 20:33:34 +01:00
Dan Brown
5c343638b6 Added base node/button for details/summary 2024-06-06 14:43:50 +01:00
Dan Brown
0722960260 Lexical: Added selection to state for aligned reading
Connected up to work with image form
2024-06-05 18:43:42 +01:00
Dan Brown
e959c468f6 Lexical: Made image resize handles functional 2024-06-05 17:18:58 +01:00
Dan Brown
ba871ec46a Lexical: Started image resize controls, Defined thorough decorator model 2024-06-05 13:04:49 +01:00
Michael Ortner
bd6e3c022f Update docker-compose.yml
Remove the version: because it is obsolete. See: https://docs.docker.com/compose/compose-file/04-version-and-name/#version-top-level-element-optional
2024-06-04 15:07:09 +02:00
Dan Brown
a74e04141c Lexical: Started build of image node and decoration UI 2024-06-03 16:56:31 +01:00
Dan Brown
7c504a10a8 Lexical: Created core modal functionality 2024-06-01 16:49:47 +01:00
Dan Brown
ae98745439 Lexical: Started on form UI 2024-05-30 16:50:55 +01:00
Dan Brown
57259aee00 Lexical: Added format previews to format buttons 2024-05-30 12:25:25 +01:00
bradenterpstra01
8759fff116 Update wysiwyg.blade.php
Remove the Shift for the numeric shortcut for incline code.

Ctrl+8 instead of Ctrl+Shift+8

I assume Mac is the same but I do not have a Mac to test with.
2024-05-29 18:01:48 -04:00
Dan Brown
dc1a40ea74 Lexical: Added ui container type
Structured UI logical to be fairly standard and mostly covered via
a base class that handles context and core dom work.
2024-05-29 20:38:31 +01:00
Dan Brown
483d9bf26c Lexical: Added a range of format buttons 2024-05-28 22:56:58 +01:00
Dan Brown
b24d60e98d Lexical: Started UI fundementals with basic button 2024-05-28 18:04:48 +01:00
Dan Brown
0f8bd869d8 Lexical: Added custom id-supporting paragraph blocks 2024-05-28 15:09:50 +01:00
Dan Brown
49546cd627 Lexical: Switched to ts for new editor build 2024-05-27 23:50:28 +01:00
Dan Brown
6e852d2e65 Lexical: Played with commands, extracted & improved callout node 2024-05-27 20:23:45 +01:00
Dan Brown
5a4f595341 Editors: Added lexical editor for testing
Started basic playground for testing lexical as a new WYSIWYG editor.
Moved out tinymce to be under wysiwyg-tinymce instead so lexical is the
default, but TinyMce code remains.
2024-05-27 15:39:41 +01:00
Dan Brown
6019d2ee14 MFA: Tweaked backup code wording
It was not clear before as it could be taken that the system would
securely store the codes.

Closes #5017
2024-05-23 11:30:53 +01:00
Dan Brown
b5375114d3 Updated version and assets for release v24.05.1 2024-05-21 11:07:36 +01:00
Dan Brown
fc13e56cea Merge branch 'development' into release 2024-05-21 11:07:10 +01:00
Dan Brown
f937bf3abb Updated translator & dependency attribution before release v24.05.1 2024-05-21 11:06:08 +01:00
Dan Brown
586e8963a8 Updated translations with latest Crowdin changes (#4994) 2024-05-21 11:04:27 +01:00
Dan Brown
bdfa76ed9a Deps: Updated php/composer packages 2024-05-20 17:28:53 +01:00
Dan Brown
d133f904d3 Auth: Changed email confirmations to use login attempt user
Negates the need for a public confirmation resend form
since we can instead just send direct to the last session login attempter.
2024-05-20 17:23:15 +01:00
Dan Brown
69af9e0dbd Routes: Added throttling to a range of auth-related endpoints
Some already throttled in some means, but this adds a simple ip-based
non-request-specific layer to many endpoints.
Related to #4993
2024-05-20 14:00:58 +01:00
Dan Brown
72c5141dec File Uploads: Added basic validation response formatting
Tested via app-level validation file limit, and then also with nginx
file post limit.
For #4996
2024-05-18 21:18:15 +01:00
Dan Brown
5651d2c43d Config: Reverted change to cache directory
Change made during Laravel 10 updates to align (Laravel made this change
much earlier in 5.x series) but it caused issues due to folder not
pre-existing and due to potentiall permission issues.
(CLI could create this during update, with non-compatible permissions
for webserver).

For #4999
2024-05-18 20:40:26 +01:00
Dan Brown
fc236f930b Dark Mode: Fixed setting labels missing dark mode handling
Fixes #5018
2024-05-18 20:37:49 +01:00
Dan Brown
570af500f4 WYSIWYG: Added justify cell range cleanup
To help override & gain control of setting text alignment in tables.

- Adds support of clearing "align" attributes in certain operations.
- Updates cell range action handling to dedupe execcommand handling.
- Adds clearing of additional alignment classes on direction control.

Closes #5011
2024-05-16 14:59:30 +01:00
Dan Brown
38913288d8 Devdocs: Fixed visual theme system lang folder reference
Made some other minor updates while there.
Fixes #4998
2024-05-16 14:15:26 +01:00
Dan Brown
c14d7d9509 Merge pull request #5008 from KiDxS/fix-notification-preferences-url-in-email
Fixed notification preferences URL in email
2024-05-16 14:11:15 +01:00
Angelo Geant Gaviola
79f5be4170 Fixed notification preferences URL in email 2024-05-14 17:04:23 +08:00
Dan Brown
77fc37ac25 Updated version and assets for release v24.05 2024-05-11 15:49:29 +01:00
Dan Brown
3424351e84 Merge branch 'development' into release 2024-05-11 15:48:49 +01:00
Dan Brown
a3a776d4a6 Updated translator & dependency attribution before release v24.05 2024-05-11 15:47:38 +01:00
Dan Brown
2b9b0f91cb Updated translations with latest Crowdin changes (#4890) 2024-05-11 15:15:10 +01:00
Dan Brown
424e8f503e Readme: Updated sponsor list 2024-05-10 11:02:20 +01:00
Dan Brown
d206129f3d Deps: Updated composer dependencies 2024-05-05 16:30:04 +01:00
Dan Brown
baad7fa9cb Merge pull request #4987 from BookStackApp/audit_api
Addition of Audit Log API Endpoint
2024-05-05 16:14:09 +01:00
Dan Brown
d54c7b4783 Audit Log: Fixed bad reference to linked entity item 2024-05-05 16:05:21 +01:00
Dan Brown
67df127c26 API: Added to, and updated, testing to cover audit log additions 2024-05-05 15:44:58 +01:00
Dan Brown
3946158e88 API: Added audit log list endpoint
Not yested covered with testing.
Changes database columns for more presentable names and for future use
to connect additional model types.
For #4316
2024-05-04 16:28:18 +01:00
Dan Brown
dd251d9e62 Merge branch 'nesges/development' into development 2024-05-04 14:00:40 +01:00
Dan Brown
5c28bcf865 Registration: Reviewed added simple honeypot, added testing
Also cleaned up old RegistrationController syntax.
Review of #4970
2024-05-04 13:59:41 +01:00
Dan Brown
7b3b28d3f8 Merge pull request #4972 from johnroyer/fix-typo-in-language-file
remove space at the beginning of description
2024-05-03 19:16:23 +01:00
Dan Brown
20e86bf376 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2024-05-03 13:40:18 +01:00
Dan Brown
f9e087330b WYSIWYG: Added text direction support for code editor popup
Editor popup will now reflect the direction of the opened code block.
This also updates in-editor codemirror instances to correcly reflect/use
the direction if set on the inner code elem.

This also defaults new code blocks, when in RTL languages, to be started
in LTR, which can then be changed via in-editor direction controls if
needed. This is on the assumption that most code will be LTR (could not
find much examples of RTL code use).

Fixes #4943
2024-05-03 13:40:00 +01:00
Dan Brown
b0720777be Merge pull request #4985 from BookStackApp/ldap_ca_cert_control
LDAP CA TLS Cert Option, PR Review and continuation
2024-05-02 23:16:16 +01:00
Dan Brown
8087123f2e LDAP: Review, testing and update of LDAP TLS CA cert control
Review of #4913
Added testing to cover option.
Updated option so it can be used for a CA directory, or a CA file.
Updated option name to be somewhat abstracted from original underling
PHP option.

Tested against Jumpcloud.
Testing took hours due to instability which was due to these settings
sticking and being unstable on change until php process restart.
Also due to little documentation for these options.
X_TLS_CACERTDIR option needs cert files to be named via specific hashes
which can be achieved via c_rehash utility.

This also adds detail on STARTTLS failure, which took a long time to
discover due to little detail out there for deeper PHP LDAP debugging.
2024-05-02 23:11:31 +01:00
Dan Brown
4c1c315594 WYSWIYG: Fixed misaligned table cell p line height
Removes an editor-specific line-height which was overriding cell
paragraph line height, causing mis-aligned style compared to viewing.
Checked a range of styles and looked at history, could not see original
purpose of the line-height removed here.
Closes #4960
2024-05-02 15:20:51 +01:00
Dan Brown
f95fb640af WYSWIYG: Improved use of object tags to embed content
- Prevented image toolbars showing for objects embeds due to tinymce
  image placeholder, and added media toolbar.
- Fixed height of object embed placeholder being forced to auto
  when in the editor, allowing height attributed to be properly
  reflected as it would on normal page view.

Closes #4974
2024-05-01 17:22:53 +01:00
Dan Brown
493d8027cd Attachments: Fixed drag into editor in Chrome
Seemed to be chrome specific from testing.
Required editors to have preventDefault called on dragover.
Tested in Chrome, FF, & Safari.
Tested in both editors, and re-tested text/image drop to ensure still
works.

Fixed #4975
2024-04-29 19:21:13 +01:00
Dan Brown
06bb55184c WYSIWYG: Fixed unexpected clearing of table cell styles
Fixes custom table cell clear-format handling since it was being called
on many format removals, not just the clear-formatting action.
This updates the code to specifically run on the RemoveFormat action
which is triggered by the clear formatting button.
Fixes #4964
2024-04-29 17:47:06 +01:00
Dan Brown
6b681961e5 LDAP: Updated default user filter placeholder format
To not conflict with env variables, and to align with placeholders used
for PDF gen command.
Added test to cover, including old format supported for
back-compatibility.
For #4967
2024-04-28 12:29:57 +01:00
Dan Brown
e1149a27e9 Merge pull request #4969 from BookStackApp/pdf_command_option
PDF Exports: New command option and library/option cleanup
2024-04-26 17:06:38 +01:00
Dan Brown
f0dd33c1b4 PDF: Added tests for pdf command, fixed old tests for changes 2024-04-26 15:39:40 +01:00
Zero
5860e1e2ce remove space at the beginning of description 2024-04-25 13:35:36 +08:00
Dan Brown
1c7128c2cb PDF: Added implmentation of command PDF option
Tested quickly manually but not yet covered by PHPUnit tests.
2024-04-24 16:09:53 +01:00
Dan Brown
40200856af PDF: Removed barryvdh snappy to use snappy direct
Also simplifies config format, and updates snappy implmentation to use
the new config file.
Not yet tested.
2024-04-24 15:13:44 +01:00
Dan Brown
bb6670d395 PDF: Started new command option, merged options, simplified dompdf
- Updated DOMPDF to direcly use library instead of depending on barry
wrapper.
- Merged existing export options file into single exports file.
- Defined option for new command option.

Related to #4732
2024-04-22 16:40:42 +01:00
nesges
0d2a268be0 whitespace only 2024-04-21 17:44:01 +02:00
nesges
16399b63be better accessibility for honepot formfield 2024-04-21 16:08:28 +02:00
Dan Brown
d949b97cc1 Merge pull request #4955 from BookStackApp/oidc_userinfo
OIDC userinfo endpoint support
2024-04-19 16:55:29 +01:00
Dan Brown
8b14a701a4 OIDC Userinfo: Fixed issues with validation logic from changes
Also updated test to suit validation changes
2024-04-19 16:43:51 +01:00
Dan Brown
0958909cd9 OIDC Userinfo: Added additional tests to cover jwks usage 2024-04-19 15:05:00 +01:00
Dan Brown
b18cee3dc4 OIDC Userinfo: Added JWT signed response support
Not yet tested, nor checked all response validations.
2024-04-19 14:12:27 +01:00
nesges
31272e60b6 add ambrosia-container to registration form as honeypot for bots: new form field "username" must not be filled 2024-04-19 09:35:09 +02:00
nesges
1b1cb18839 fixed mislabeling of name input 2024-04-19 09:18:34 +02:00
Dan Brown
fa543bbd4d OIDC Userinfo: Started writing tests to cover userinfo calling 2024-04-17 23:26:56 +01:00
Dan Brown
7d7cd32ca7 OIDC Userinfo: Added userinfo data validation, seperated from id token
Wrapped userinfo response in its own class for additional handling and
validation.
Updated userdetails to take abstract claim data, to be populated by
either userinfo data or id token data.
2024-04-17 18:23:58 +01:00
Dan Brown
a71c8c60b7 OIDC: Extracted user detail handling to own OidcUserDetails class
Allows a proper defined object instead of an array an extracts related
logic out of OidcService.
Updated userinfo to only be called if we're missing details.
2024-04-16 18:14:22 +01:00
Dan Brown
9183e7f2fe OIDC Userinfo: Labelled changes to be made during review 2024-04-16 15:52:55 +01:00
Dan Brown
d640411adb OIDC: Cleaned up provider settings, added extra validation
- Added endpoint validation to ensure HTTPS as per spec
- Added some missing types
- Removed redirectUri from OidcProviderSettings since it's not a
  provider-based setting, but a setting for the oauth client, so
  extracted that back to service.
2024-04-16 15:19:51 +01:00
Dan Brown
dc6013fd7e Merge branch 'development' into lukeshu/oidc-development 2024-04-16 14:57:36 +01:00
Dan Brown
80ac66e0a6 Code Editor: Added scala to language list
For #4953
2024-04-16 14:44:17 +01:00
Dan Brown
f05ec4cc26 Tags: Stopped recycle bin tags being counted on index
For #4892
Added test to cover.
2024-04-15 18:44:59 +01:00
Dan Brown
d9ff001ffe Merge pull request #4904 from C0rn3j/optimize-images
15KB lossless optimization via oxipng(PNG) and svgo(SVG)
2024-04-15 18:07:29 +01:00
Dan Brown
0f6cb9ed84 Content styles: Made links underlined for visibility
Inline with A11y recommendations where color may not be reliable on its
own.
Tested various content link scenarios across chrome, safari & FF.
For #4939
2024-04-13 15:48:39 +01:00
Dan Brown
dde1f27882 Merge pull request #4930 from BookStackApp/split_md_js
JS Build: Split markdown to own file, updated packages
2024-04-08 14:46:06 +01:00
Dan Brown
f5e6f9574d JS Build: Split markdown to own file, updated packages
Markdown-related code was growing, representing half of app.js main
bundle code while only being needed in one view/scenario.
This extracts markdown related code to its own built file.
Related to #4858
2024-04-08 14:41:51 +01:00
Dan Brown
ee40adf11a Merge pull request #4921 from BookStackApp/v24-02
v23.02.3 changes
2024-04-05 15:21:05 +01:00
Dan Brown
606f9d92d0 Updated version and assets for release v24.02.3 2024-04-05 15:20:08 +01:00
Dan Brown
a5e25abb9c Merge branch 'v24-02' into release 2024-04-05 15:19:34 +01:00
Dan Brown
3e23f456fe CSS: Removed redundant calc 2024-04-05 15:18:58 +01:00
Dan Brown
b9e2d33ed4 Page Content: Aligned max-width across viewer and editors
For #4916
2024-04-05 15:06:08 +01:00
Dan Brown
19f78dbe6c WYSIWYG descriptions: Allowed anchor target attrs
Allowed since this is a control in the editor UI, but would previously
be stripped by editor config & server-side filtering.
For #4925
2024-04-03 16:46:53 +01:00
Dan Brown
a33dbcb04a References: Fixed references count/list recycle bin interaction
Count and reference list would get references then attempt to load
entities, which could fail to load if in the recycle bin.
This updates the queries to effectively ignore references for items we
can't see (in recycle bin).
Added test to cover.

For #4918
2024-04-01 17:08:53 +01:00
Dan Brown
58f6219cb3 Code: Fixed highlighting issues when no code language set
For #4917
2024-03-31 14:33:08 +01:00
Matt Moore
18269f2c60 Add LDAP_TLS_CACERTFILE to example env file 2024-03-27 13:17:25 +00:00
Matt Moore
06ef95dc5f Change to allow override of CA CERT for LDAPS
Using the env LDAP_TLS_CACERTFILE to set a file to use to override
the CA CERT used to verify LDAPS connections. This is to make this
process easier for docker use.
2024-03-26 16:30:04 +00:00
Martin Rys
76c7166268 Use zopfli for oxipng for extra 3KB~ 2024-03-26 12:31:54 +01:00
Dan Brown
6c063f424c Merge pull request #4907 from BookStackApp/licensing_update
Dependency Licensing Improvements
2024-03-24 12:01:01 +00:00
Dan Brown
3345680f7d Licensing: Added license gen as composer command 2024-03-24 11:58:31 +00:00
Dan Brown
a2fd80954b Licensing: Added links and tests for new licenses endpoint
For #4907
2024-03-23 22:04:18 +00:00
Dan Brown
0c524c7c8f Licensing: Added licenses app view
Extracted many methods to a new "MetaController" in the process.
2024-03-23 16:31:13 +00:00
Martin Rys
5f306a11e7 15KB lossless optimization via oxipng(PNG) and svgo(SVG) 2024-03-23 16:33:11 +01:00
Dan Brown
ed956a4cf0 Licensing: Updated license gen scripts to share logic 2024-03-23 15:33:05 +00:00
Dan Brown
55a2a6db88 Licensing: Added script to gen info for JS packages 2024-03-23 15:19:58 +00:00
Dan Brown
f789359886 Licensing: Added script to build PHP library licensing information 2024-03-22 14:44:23 +00:00
Dan Brown
c221a00e1e Migrations: Added prefix support to schema inspection 2024-03-19 10:30:26 +00:00
Dan Brown
83913af68b Merge branch 'development' into C0rn3j/development 2024-03-18 14:35:16 +00:00
Dan Brown
fa5395a02b Meta: Updated workflows, licence and readme
- Updated license year
- Updated some readme wording, removed lapsed sponsor, Removed twitter
  link, added link to alt github source
- Update cache action for GH workflows since GH was complaining
2024-03-18 14:26:31 +00:00
Dan Brown
85dd71507e Merge pull request #4903 from BookStackApp/laravel10
Framework: Upgrade from Laravel 9 to 10
2024-03-17 17:00:03 +00:00
Dan Brown
28d6292278 Framework: Addressed deprecations 2024-03-17 16:52:19 +00:00
Dan Brown
b4b84f81a0 Deps: Updated custom symfony/mailer package
Done during #4903 work
2024-03-17 16:32:59 +00:00
Dan Brown
2345fd4677 Deps: Updated intervention library from 2 to 3
Major version change, required some changes to API
For #4903
2024-03-17 16:03:12 +00:00
Dan Brown
3250fc732c Testing: Updated PHPUnit from 9 to 10
For #4903
2024-03-17 15:41:11 +00:00
Dan Brown
45d52f27ae Migrations: Updated with type hints instead of php doc
Also updated code to properly import used facades.
For #4903
2024-03-17 15:29:09 +00:00
Dan Brown
d6b7717985 Framework: Fixed issues breaking tests
For #4903
2024-03-16 15:26:34 +00:00
Dan Brown
794671ef32 Framework: Upgrade from Laravel 9 to 10
Following Laravel guidance and GitHub diff.
Not yet in tested state with app-specific changes made.
2024-03-16 15:12:14 +00:00
Martin Rys
70479df5dc Dockerfile: Don't cache 50MB of lists and use a single layer, make it pretty 2024-03-12 14:04:33 +01:00
Dan Brown
07761524af Dev: Fixed flaky OIDC test, updated dev version 2024-03-12 12:08:26 +00:00
Dan Brown
2ed931aeed Updated minimum PHP version from 8.0 to 8.1
For #4893
2024-03-12 11:29:51 +00:00
Dan Brown
b310e87e4c Updated version and assets for release v24.02.2 2024-03-11 14:30:48 +00:00
Dan Brown
425baf9d6e Merge branch 'development' into release 2024-03-10 18:46:05 +00:00
Dan Brown
0d3de40459 Updated translator attribution before release v24.02.1 2024-03-10 18:45:32 +00:00
Dan Brown
3619f79ca6 Updated translations with latest Crowdin changes (#4877) 2024-03-10 18:36:12 +00:00
Dan Brown
c9d9ad10f2 Merge branch 'totp-patch' into development 2024-03-10 18:32:02 +00:00
Dan Brown
d5a689366c MFA: Copied autocomplete changes from totp to backup codes
Also added tests to cover.
Related to #4849
2024-03-10 18:31:01 +00:00
Dan Brown
bc24a1360f TOTP: Added one-time-code autofill
During review of #4849
Tested on Firefox & Chromium desktop.
2024-03-10 18:24:42 +00:00
Dan Brown
77f125208e Page nav: Fixed nbsp being represented as nothing
Now represented in page nav using a normal space to avoid complete
removal of space.
Added test to cover.
For #4836
2024-03-09 15:52:09 +00:00
Dan Brown
b7d4bd5bce Breadcrumbs: Set book/shelf lists to use name ordering
Previously in database order (id) which is not predictable
nor parsable for users.
For #4876
2024-03-09 15:24:44 +00:00
Dan Brown
5a5f0b8de9 Page Display: Fixed highlighting for elements in nested details
For #4878
2024-03-09 15:07:51 +00:00
Mattic
c53c9f6866 Turned off autocomplete for TOTP codes
Small QOL change to turn off autocomplete when entering TOTP codes since they're one time use only.
2024-02-15 09:22:35 -06:00
Luke T. Shumaker
c76d12d1de Oidc: Properly query the UserInfo Endpoint
BooksStack's OIDC Client requests the 'profile' and 'email' scope values
in order to have access to the 'name', 'email', and other claims.  It
looks for these claims in the ID Token that is returned along with the
Access Token.

However, the OIDC-core specification section 5.4 [1] only requires that
the Provider include those claims in the ID Token *if* an Access Token is
not also issued.  If an Access Token is issued, the Provider can leave out
those claims from the ID Token, and the Client is supposed to obtain them
by submitting the Access Token to the UserInfo Endpoint.

So I suppose it's just good luck that the OIDC Providers that BookStack
has been tested with just so happen to also stick those claims in the ID
Token even though they don't have to.  But others (in particular:
https://login.infomaniak.com) don't do so, and require fetching the
UserInfo Endpoint.)

A workaround is currently possible by having the user write a theme with a
ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE hook that fetches the UserInfo
Endpoint.  This workaround isn't great, for a few reasons:
 1. Asking the user to implement core parts of the OIDC protocol is silly.
 2. The user either needs to re-fetch the .well-known/openid-configuration
    file to discover the endpoint (adding yet another round-trip to each
    login) or hard-code the endpoint, which is fragile.
 3. The hook doesn't receive the HTTP client configuration.

So, have BookStack's OidcService fetch the UserInfo Endpoint and inject
those claims into the ID Token, if a UserInfo Endpoint is defined.
Two points about this:
 - Injecting them into the ID Token's claims is the most obvious approach
   given the current code structure; though I'm not sure it is the best
   approach, perhaps it should instead fetch the user info in
   processAuthorizationResponse() and pass that as an argument to
   processAccessTokenCallback() which would then need a bit of
   restructuring.  But this made sense because it's also how the
   ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE hook works.
 - OIDC *requires* that a UserInfo Endpoint exists, so why bother with
   that "if a UserInfo Endpoint is defined" bit?  Simply out of an
   abundance of caution that there's an existing BookStack user that is
   relying on it not fetching the UserInfo Endpoint in order to work with
   a non-compliant OIDC Provider.

[1]: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
2023-12-15 14:11:48 -07:00
1061 changed files with 79520 additions and 8503 deletions

View File

@@ -215,10 +215,11 @@ LDAP_SERVER=false
LDAP_BASE_DN=false
LDAP_DN=false
LDAP_PASS=false
LDAP_USER_FILTER=false
LDAP_USER_FILTER="(&(uid={user}))"
LDAP_VERSION=false
LDAP_START_TLS=false
LDAP_TLS_INSECURE=false
LDAP_TLS_CA_CERT=false
LDAP_ID_ATTRIBUTE=uid
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
@@ -267,6 +268,7 @@ OIDC_ISSUER_DISCOVER=false
OIDC_PUBLIC_KEY=null
OIDC_AUTH_ENDPOINT=null
OIDC_TOKEN_ENDPOINT=null
OIDC_USERINFO_ENDPOINT=null
OIDC_ADDITIONAL_SCOPES=null
OIDC_DUMP_USER_DETAILS=false
OIDC_USER_TO_GROUPS=false
@@ -324,6 +326,19 @@ FILE_UPLOAD_SIZE_LIMIT=50
# Can be 'a4' or 'letter'.
EXPORT_PAGE_SIZE=a4
# Export PDF Command
# Set a command which can be used to convert a HTML file into a PDF file.
# When false this will not be used.
# String values represent the command to be called for conversion.
# Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
EXPORT_PDF_COMMAND=false
# Export PDF Command Timeout
# The number of seconds that the export PDF command will run before a timeout occurs.
# Only applies for the EXPORT_PDF_COMMAND option, not for DomPDF or wkhtmltopdf.
EXPORT_PDF_COMMAND_TIMEOUT=15
# Set path to wkhtmltopdf binary for PDF generation.
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
# When false, BookStack will attempt to find a wkhtmltopdf in the application

View File

@@ -141,7 +141,7 @@ Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
MatthieuParis :: French
Douradinho :: Portuguese, Brazilian; Portuguese
Gaku Yaguchi (tama11) :: Japanese
johnroyer :: Chinese Traditional
Zero Huang (johnroyer) :: Chinese Traditional
jackaaa :: Chinese Traditional
Irfan Hukama Arsyad (IrfanArsyad) :: Indonesian
Jeff Huang (s8321414) :: Chinese Traditional
@@ -347,7 +347,7 @@ Taygun Yıldırım (yildirimtaygun) :: Turkish
robing29 :: German
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
Igor V Belousov (biv) :: Russian
David Bauer (davbauer) :: German
David Bauer (davbauer) :: German; German Informal
Guttorm Hveem (guttormhveem) :: Norwegian Nynorsk; Norwegian Bokmal
Minh Giang Truong (minhgiang1204) :: Vietnamese
Ioannis Ioannides (i.ioannides) :: Greek
@@ -389,7 +389,7 @@ Marc Hagen (MarcHagen) :: Dutch
Kasper Alsøe (zeonos) :: Danish
sultani :: Persian
renge :: Korean
TheGatesDev (thegatesdev) :: Dutch
Tim (thegatesdev) :: Dutch; German Informal; French; Romanian; Catalan; Czech; Danish; German; Finnish; Hungarian; Italian; Japanese; Korean; Polish; Russian; Ukrainian; Chinese Simplified; Chinese Traditional; Portuguese, Brazilian; Persian; Spanish, Argentina; Croatian; Norwegian Nynorsk; Estonian; Uzbek; Norwegian Bokmal
Irdi (irdiOL) :: Albanian
KateBarber :: Welsh
Twister (theuncles75) :: Hebrew
@@ -406,3 +406,46 @@ Jan Picka (polipones) :: Czech
diogoalex991 :: Portuguese
Ehsan Sadeghi (ehsansadeghi) :: Persian
ka_picit :: Danish
cracrayol :: French
CapuaSC :: Dutch
Guardian75 :: German Informal
mr-kanister :: German
Michele Bastianelli (makoblaster) :: Italian
jespernissen :: Danish
Andrey (avmaksimov) :: Russian
Gonzalo Loyola (AlFcl) :: Spanish, Argentina; Spanish
grobert63 :: French
wusst. (Supporti) :: German
MaximMaximS :: Czech
damian-klima :: Slovak
crow_ :: Latvian
JocelynDelalande :: French
Jan (JW-CH) :: German Informal
Timo B (lommes) :: German Informal
Erik Lundstedt (Erik.Lundstedt) :: Swedish
yngams (younessmouhid) :: Arabic
Ohadp :: Hebrew
cbridi :: Portuguese, Brazilian
nanangsb :: Indonesian
Michal Melich (michalmelich) :: Czech
David (david-prv) :: German; German Informal
Larry (lahoje) :: Swedish
Marcia dos Santos (marciab80) :: Portuguese
Ricard López Torres (richilpez.torres) :: Catalan
sarahalves7 :: Portuguese, Brazilian
petr.husak :: Czech
javadataherian :: Persian
Ludo-code :: French
hollsten :: Swedish
Ngoc Lan Phung (lanpncz) :: Vietnamese
Worive :: Catalan
Илья Скаба (skabailya) :: Russian
Irjan Olsen (Irch) :: Norwegian Bokmal
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
Red (RedVortex) :: Hebrew
xgrug :: Chinese Simplified
HrCalmar :: Danish
Avishay Rapp (AvishayRapp) :: Hebrew
matthias4217 :: French
Berke BOYLU2 (berkeboylu2) :: Turkish
etwas7B :: German

View File

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

View File

@@ -13,9 +13,9 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v4
- name: Install NPM deps
run: npm ci

29
.github/workflows/test-js.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: test-js
on:
push:
paths:
- '**.js'
- '**.ts'
- '**.json'
pull_request:
paths:
- '**.js'
- '**.ts'
- '**.json'
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install NPM deps
run: npm ci
- name: Run TypeScript type checking
run: npm run ts:lint
- name: Run JavaScript tests
run: npm run test

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015-2023, Dan Brown and the BookStack Project contributors.
Copyright (c) 2015-2024, 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

@@ -32,13 +32,17 @@ class ConfirmEmailController extends Controller
/**
* Shows a notice that a user's email address has not been confirmed,
* Also has the option to re-send the confirmation email.
* along with the option to re-send the confirmation email.
*/
public function showAwaiting()
{
$user = $this->loginService->getLastLoginAttemptUser();
if ($user === null) {
$this->showErrorNotification(trans('errors.login_user_not_found'));
return redirect('/login');
}
return view('auth.user-unconfirmed', ['user' => $user]);
return view('auth.register-confirm-awaiting');
}
/**
@@ -90,19 +94,24 @@ class ConfirmEmailController extends Controller
/**
* Resend the confirmation email.
*/
public function resend(Request $request)
public function resend()
{
$this->validate($request, [
'email' => ['required', 'email', 'exists:users,email'],
]);
$user = $this->userRepo->getByEmail($request->get('email'));
$user = $this->loginService->getLastLoginAttemptUser();
if ($user === null) {
$this->showErrorNotification(trans('errors.login_user_not_found'));
return redirect('/login');
}
try {
$this->emailConfirmationService->sendConfirmation($user);
} catch (ConfirmationEmailException $e) {
$this->showErrorNotification($e->getMessage());
return redirect('/login');
} catch (Exception $e) {
$this->showErrorNotification(trans('auth.email_confirm_send_error'));
return redirect('/register/confirm');
return redirect('/register/awaiting');
}
$this->showSuccessNotification(trans('auth.email_confirm_resent'));

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Sleep;
class ForgotPasswordController extends Controller
{
@@ -32,6 +33,10 @@ class ForgotPasswordController extends Controller
'email' => ['required', 'email'],
]);
// Add random pause to the response to help avoid time-base sniffing
// of valid resets via slower email send handling.
Sleep::for(random_int(1000, 3000))->milliseconds();
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.

View File

@@ -17,7 +17,7 @@ trait HandlesPartialLogins
$user = auth()->user() ?? $loginService->getLastLoginAttemptUser();
if (!$user) {
throw new NotFoundException('A user for this action could not be found');
throw new NotFoundException(trans('errors.login_user_not_found'));
}
return $user;

View File

@@ -19,20 +19,25 @@ class MfaTotpController extends Controller
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret';
public function __construct(
protected TotpService $totp
) {
}
/**
* Show a view that generates and displays a TOTP QR code.
*/
public function generate(TotpService $totp)
public function generate()
{
if (session()->has(static::SETUP_SECRET_SESSION_KEY)) {
$totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY));
} else {
$totpSecret = $totp->generateSecret();
$totpSecret = $this->totp->generateSecret();
session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
}
$qrCodeUrl = $totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
$svg = $totp->generateQrCodeSvg($qrCodeUrl);
$qrCodeUrl = $this->totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
$svg = $this->totp->generateQrCodeSvg($qrCodeUrl);
$this->setPageTitle(trans('auth.mfa_gen_totp_title'));
@@ -56,7 +61,7 @@ class MfaTotpController extends Controller
'code' => [
'required',
'max:12', 'min:4',
new TotpValidationRule($totpSecret),
new TotpValidationRule($totpSecret, $this->totp),
],
]);
@@ -87,7 +92,7 @@ class MfaTotpController extends Controller
'code' => [
'required',
'max:12', 'min:4',
new TotpValidationRule($totpSecret),
new TotpValidationRule($totpSecret, $this->totp),
],
]);

View File

@@ -15,24 +15,13 @@ use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
protected SocialDriverManager $socialDriverManager;
protected RegistrationService $registrationService;
protected LoginService $loginService;
/**
* Create a new controller instance.
*/
public function __construct(
SocialDriverManager $socialDriverManager,
RegistrationService $registrationService,
LoginService $loginService
protected SocialDriverManager $socialDriverManager,
protected RegistrationService $registrationService,
protected LoginService $loginService
) {
$this->middleware('guest');
$this->middleware('guard:standard');
$this->socialDriverManager = $socialDriverManager;
$this->registrationService = $registrationService;
$this->loginService = $loginService;
}
/**
@@ -87,6 +76,8 @@ class RegisterController extends Controller
'name' => ['required', 'min:2', 'max:100'],
'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => ['required', Password::default()],
// Basic honey for bots that must not be filled in
'username' => ['prohibited'],
]);
}
}

View File

@@ -15,14 +15,11 @@ use Illuminate\Validation\Rules\Password as PasswordRule;
class ResetPasswordController extends Controller
{
protected LoginService $loginService;
public function __construct(LoginService $loginService)
{
public function __construct(
protected LoginService $loginService
) {
$this->middleware('guest');
$this->middleware('guard:standard');
$this->loginService = $loginService;
}
/**

View File

@@ -17,7 +17,7 @@ class EmailConfirmationService extends UserTokenService
*
* @throws ConfirmationEmailException
*/
public function sendConfirmation(User $user)
public function sendConfirmation(User $user): void
{
if ($user->email_confirmed) {
throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login');

View File

@@ -52,13 +52,25 @@ class Ldap
*
* @param resource|\LDAP\Connection $ldapConnection
*
* @return resource|\LDAP\Result
* @return \LDAP\Result|array|false
*/
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = null)
{
return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
}
/**
* Read an entry from the LDAP tree.
*
* @param resource|\Ldap\Connection $ldapConnection
*
* @return \LDAP\Result|array|false
*/
public function read($ldapConnection, string $baseDn, string $filter, array $attributes = null)
{
return ldap_read($ldapConnection, $baseDn, $filter, $attributes);
}
/**
* Get entries from an LDAP search result.
*

View File

@@ -209,6 +209,12 @@ class LdapService
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
}
// Configure any user-provided CA cert files for LDAP.
// This option works globally and must be set before a connection is created.
if ($this->config['tls_ca_cert']) {
$this->configureTlsCaCerts($this->config['tls_ca_cert']);
}
$ldapHost = $this->parseServerString($this->config['server']);
$ldapConnection = $this->ldap->connect($ldapHost);
@@ -223,7 +229,14 @@ class LdapService
// Start and verify TLS if it's enabled
if ($this->config['start_tls']) {
$started = $this->ldap->startTls($ldapConnection);
try {
$started = $this->ldap->startTls($ldapConnection);
} catch (\Exception $exception) {
$error = $exception->getMessage() . ' :: ' . ldap_error($ldapConnection);
ldap_get_option($ldapConnection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $detail);
Log::info("LDAP STARTTLS failure: {$error} {$detail}");
throw new LdapException('Could not start TLS connection. Further details in the application log.');
}
if (!$started) {
throw new LdapException('Could not start TLS connection');
}
@@ -234,6 +247,33 @@ class LdapService
return $this->ldapConnection;
}
/**
* Configure TLS CA certs globally for ldap use.
* This will detect if the given path is a directory or file, and set the relevant
* LDAP TLS options appropriately otherwise throw an exception if no file/folder found.
*
* Note: When using a folder, certificates are expected to be correctly named by hash
* which can be done via the c_rehash utility.
*
* @throws LdapException
*/
protected function configureTlsCaCerts(string $caCertPath): void
{
$errMessage = "Provided path [{$caCertPath}] for LDAP TLS CA certs could not be resolved to an existing location";
$path = realpath($caCertPath);
if ($path === false) {
throw new LdapException($errMessage);
}
if (is_dir($path)) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTDIR, $path);
} else if (is_file($path)) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTFILE, $path);
} else {
throw new LdapException($errMessage);
}
}
/**
* Parse an LDAP server string and return the host suitable for a connection.
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
@@ -249,13 +289,18 @@ class LdapService
/**
* Build a filter string by injecting common variables.
* Both "${var}" and "{var}" style placeholders are supported.
* Dollar based are old format but supported for compatibility.
*/
protected function buildFilter(string $filterString, array $attrs): string
{
$newAttrs = [];
foreach ($attrs as $key => $attrText) {
$newKey = '${' . $key . '}';
$newAttrs[$newKey] = $this->ldap->escape($attrText);
$escapedText = $this->ldap->escape($attrText);
$oldVarKey = '${' . $key . '}';
$newVarKey = '{' . $key . '}';
$newAttrs[$oldVarKey] = $escapedText;
$newAttrs[$newVarKey] = $escapedText;
}
return strtr($filterString, $newAttrs);
@@ -276,94 +321,105 @@ class LdapService
return [];
}
$userGroups = $this->groupFilter($user);
$userGroups = $this->extractGroupsFromSearchResponseEntry($user);
$allGroups = $this->getGroupsRecursive($userGroups, []);
$formattedGroups = $this->extractGroupNamesFromLdapGroupDns($allGroups);
if ($this->config['dump_user_groups']) {
throw new JsonDebugException([
'details_from_ldap' => $user,
'parsed_direct_user_groups' => $userGroups,
'parsed_recursive_user_groups' => $allGroups,
'details_from_ldap' => $user,
'parsed_direct_user_groups' => $userGroups,
'parsed_recursive_user_groups' => $allGroups,
'parsed_resulting_group_names' => $formattedGroups,
]);
}
return $allGroups;
return $formattedGroups;
}
protected function extractGroupNamesFromLdapGroupDns(array $groupDNs): array
{
$names = [];
foreach ($groupDNs as $groupDN) {
$exploded = $this->ldap->explodeDn($groupDN, 1);
if ($exploded !== false && count($exploded) > 0) {
$names[] = $exploded[0];
}
}
return array_unique($names);
}
/**
* Get the parent groups of an array of groups.
* Build an array of all relevant groups DNs after recursively scanning
* across parents of the groups given.
*
* @throws LdapException
*/
private function getGroupsRecursive(array $groupsArray, array $checked): array
protected function getGroupsRecursive(array $groupDNs, array $checked): array
{
$groupsToAdd = [];
foreach ($groupsArray as $groupName) {
if (in_array($groupName, $checked)) {
foreach ($groupDNs as $groupDN) {
if (in_array($groupDN, $checked)) {
continue;
}
$parentGroups = $this->getGroupGroups($groupName);
$parentGroups = $this->getParentsOfGroup($groupDN);
$groupsToAdd = array_merge($groupsToAdd, $parentGroups);
$checked[] = $groupName;
$checked[] = $groupDN;
}
$groupsArray = array_unique(array_merge($groupsArray, $groupsToAdd), SORT_REGULAR);
$uniqueDNs = array_unique(array_merge($groupDNs, $groupsToAdd), SORT_REGULAR);
if (empty($groupsToAdd)) {
return $groupsArray;
return $uniqueDNs;
}
return $this->getGroupsRecursive($groupsArray, $checked);
return $this->getGroupsRecursive($uniqueDNs, $checked);
}
/**
* Get the parent groups of a single group.
*
* @throws LdapException
*/
private function getGroupGroups(string $groupName): array
protected function getParentsOfGroup(string $groupDN): array
{
$groupsAttr = strtolower($this->config['group_attribute']);
$ldapConnection = $this->getConnection();
$this->bindSystemUser($ldapConnection);
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
$this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
$baseDn = $this->config['base_dn'];
$groupsAttr = strtolower($this->config['group_attribute']);
$groupFilter = 'CN=' . $this->ldap->escape($groupName);
$groups = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $groupFilter, [$groupsAttr]);
if ($groups['count'] === 0) {
$read = $this->ldap->read($ldapConnection, $groupDN, '(objectClass=*)', [$groupsAttr]);
$results = $this->ldap->getEntries($ldapConnection, $read);
if ($results['count'] === 0) {
return [];
}
return $this->groupFilter($groups[0]);
return $this->extractGroupsFromSearchResponseEntry($results[0]);
}
/**
* Filter out LDAP CN and DN language in a ldap search return.
* Gets the base CN (common name) of the string.
* Extract an array of group DN values from the given LDAP search response entry
*/
protected function groupFilter(array $userGroupSearchResponse): array
protected function extractGroupsFromSearchResponseEntry(array $ldapEntry): array
{
$groupsAttr = strtolower($this->config['group_attribute']);
$ldapGroups = [];
$groupDNs = [];
$count = 0;
if (isset($userGroupSearchResponse[$groupsAttr]['count'])) {
$count = (int) $userGroupSearchResponse[$groupsAttr]['count'];
if (isset($ldapEntry[$groupsAttr]['count'])) {
$count = (int) $ldapEntry[$groupsAttr]['count'];
}
for ($i = 0; $i < $count; $i++) {
$dnComponents = $this->ldap->explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1);
if (!in_array($dnComponents[0], $ldapGroups)) {
$ldapGroups[] = $dnComponents[0];
$dn = $ldapEntry[$groupsAttr][$i];
if (!in_array($dn, $groupDNs)) {
$groupDNs[] = $dn;
}
}
return $ldapGroups;
return $groupDNs;
}
/**

View File

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

View File

@@ -2,58 +2,8 @@
namespace BookStack\Access\Oidc;
class OidcIdToken
class OidcIdToken extends OidcJwtWithClaims implements ProvidesClaims
{
protected array $header;
protected array $payload;
protected string $signature;
protected string $issuer;
protected array $tokenParts = [];
/**
* @var array[]|string[]
*/
protected array $keys;
public function __construct(string $token, string $issuer, array $keys)
{
$this->keys = $keys;
$this->issuer = $issuer;
$this->parse($token);
}
/**
* Parse the token content into its components.
*/
protected function parse(string $token): void
{
$this->tokenParts = explode('.', $token);
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
}
/**
* Parse a Base64-JSON encoded token part.
* Returns the data as a key-value array or empty array upon error.
*/
protected function parseEncodedTokenPart(string $part): array
{
$json = $this->base64UrlDecode($part) ?: '{}';
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Base64URL decode. Needs some character conversions to be compatible
* with PHP's default base64 handling.
*/
protected function base64UrlDecode(string $encoded): string
{
return base64_decode(strtr($encoded, '-_', '+/'));
}
/**
* Validate all possible parts of the id token.
*
@@ -61,91 +11,12 @@ class OidcIdToken
*/
public function validate(string $clientId): bool
{
$this->validateTokenStructure();
$this->validateTokenSignature();
parent::validateCommonTokenDetails($clientId);
$this->validateTokenClaims($clientId);
return true;
}
/**
* Fetch a specific claim from this token.
* Returns null if it is null or does not exist.
*
* @return mixed|null
*/
public function getClaim(string $claim)
{
return $this->payload[$claim] ?? null;
}
/**
* Get all returned claims within the token.
*/
public function getAllClaims(): array
{
return $this->payload;
}
/**
* Replace the existing claim data of this token with that provided.
*/
public function replaceClaims(array $claims): void
{
$this->payload = $claims;
}
/**
* Validate the structure of the given token and ensure we have the required pieces.
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenStructure(): void
{
foreach (['header', 'payload'] as $prop) {
if (empty($this->$prop) || !is_array($this->$prop)) {
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
}
}
if (empty($this->signature) || !is_string($this->signature)) {
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
}
}
/**
* Validate the signature of the given token and ensure it validates against the provided key.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenSignature(): void
{
if ($this->header['alg'] !== 'RS256') {
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
}
$parsedKeys = array_map(function ($key) {
try {
return new OidcJwtSigningKey($key);
} catch (OidcInvalidKeyException $e) {
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
}
}, $this->keys);
$parsedKeys = array_filter($parsedKeys);
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
/** @var OidcJwtSigningKey $parsedKey */
foreach ($parsedKeys as $parsedKey) {
if ($parsedKey->verify($contentToSign, $this->signature)) {
return;
}
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
}
/**
* Validate the claims of the token.
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
@@ -156,27 +27,18 @@ class OidcIdToken
{
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
// MUST exactly match the value of the iss (issuer) Claim.
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
}
// Already done in parent.
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
// if the ID Token does not list the Client as a valid audience, or if it contains additional
// audiences not trusted by the Client.
if (empty($this->payload['aud'])) {
throw new OidcInvalidTokenException('Missing token audience value');
}
// Partially done in parent.
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
if (count($aud) !== 1) {
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
}
if ($aud[0] !== $clientId) {
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
}
// 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
// NOTE: Addressed by enforcing a count of 1 above.

View File

@@ -0,0 +1,174 @@
<?php
namespace BookStack\Access\Oidc;
class OidcJwtWithClaims implements ProvidesClaims
{
protected array $header;
protected array $payload;
protected string $signature;
protected string $issuer;
protected array $tokenParts = [];
/**
* @var array[]|string[]
*/
protected array $keys;
public function __construct(string $token, string $issuer, array $keys)
{
$this->keys = $keys;
$this->issuer = $issuer;
$this->parse($token);
}
/**
* Parse the token content into its components.
*/
protected function parse(string $token): void
{
$this->tokenParts = explode('.', $token);
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
}
/**
* Parse a Base64-JSON encoded token part.
* Returns the data as a key-value array or empty array upon error.
*/
protected function parseEncodedTokenPart(string $part): array
{
$json = $this->base64UrlDecode($part) ?: '{}';
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Base64URL decode. Needs some character conversions to be compatible
* with PHP's default base64 handling.
*/
protected function base64UrlDecode(string $encoded): string
{
return base64_decode(strtr($encoded, '-_', '+/'));
}
/**
* Validate common parts of OIDC JWT tokens.
*
* @throws OidcInvalidTokenException
*/
public function validateCommonTokenDetails(string $clientId): bool
{
$this->validateTokenStructure();
$this->validateTokenSignature();
$this->validateCommonClaims($clientId);
return true;
}
/**
* Fetch a specific claim from this token.
* Returns null if it is null or does not exist.
*/
public function getClaim(string $claim): mixed
{
return $this->payload[$claim] ?? null;
}
/**
* Get all returned claims within the token.
*/
public function getAllClaims(): array
{
return $this->payload;
}
/**
* Replace the existing claim data of this token with that provided.
*/
public function replaceClaims(array $claims): void
{
$this->payload = $claims;
}
/**
* Validate the structure of the given token and ensure we have the required pieces.
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenStructure(): void
{
foreach (['header', 'payload'] as $prop) {
if (empty($this->$prop) || !is_array($this->$prop)) {
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
}
}
if (empty($this->signature) || !is_string($this->signature)) {
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
}
}
/**
* Validate the signature of the given token and ensure it validates against the provided key.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenSignature(): void
{
if ($this->header['alg'] !== 'RS256') {
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
}
$parsedKeys = array_map(function ($key) {
try {
return new OidcJwtSigningKey($key);
} catch (OidcInvalidKeyException $e) {
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
}
}, $this->keys);
$parsedKeys = array_filter($parsedKeys);
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
/** @var OidcJwtSigningKey $parsedKey */
foreach ($parsedKeys as $parsedKey) {
if ($parsedKey->verify($contentToSign, $this->signature)) {
return;
}
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
}
/**
* Validate common claims for OIDC JWT tokens.
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation
* and https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
*
* @throws OidcInvalidTokenException
*/
protected function validateCommonClaims(string $clientId): void
{
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
// MUST exactly match the value of the iss (issuer) Claim.
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
}
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
// if the ID Token does not list the Client as a valid audience.
if (empty($this->payload['aud'])) {
throw new OidcInvalidTokenException('Missing token audience value');
}
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
if (!in_array($clientId, $aud, true)) {
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
}
}
}

View File

@@ -18,10 +18,10 @@ class OidcProviderSettings
public string $issuer;
public string $clientId;
public string $clientSecret;
public ?string $redirectUri;
public ?string $authorizationEndpoint;
public ?string $tokenEndpoint;
public ?string $endSessionEndpoint;
public ?string $userinfoEndpoint;
/**
* @var string[]|array[]
@@ -37,7 +37,7 @@ class OidcProviderSettings
/**
* Apply an array of settings to populate setting properties within this class.
*/
protected function applySettingsFromArray(array $settingsArray)
protected function applySettingsFromArray(array $settingsArray): void
{
foreach ($settingsArray as $key => $value) {
if (property_exists($this, $key)) {
@@ -51,9 +51,9 @@ class OidcProviderSettings
*
* @throws InvalidArgumentException
*/
protected function validateInitial()
protected function validateInitial(): void
{
$required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
$required = ['clientId', 'clientSecret', 'issuer'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
@@ -73,12 +73,20 @@ class OidcProviderSettings
public function validate(): void
{
$this->validateInitial();
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
}
}
$endpointProperties = ['tokenEndpoint', 'authorizationEndpoint', 'userinfoEndpoint'];
foreach ($endpointProperties as $prop) {
if (is_string($this->$prop) && !str_starts_with($this->$prop, 'https://')) {
throw new InvalidArgumentException("Endpoint value for \"{$prop}\" must start with https://");
}
}
}
/**
@@ -86,7 +94,7 @@ class OidcProviderSettings
*
* @throws OidcIssuerDiscoveryException
*/
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes): void
{
try {
$cacheKey = 'oidc-discovery::' . $this->issuer;
@@ -128,6 +136,10 @@ class OidcProviderSettings
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
}
if (!empty($result['userinfo_endpoint'])) {
$discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint'];
}
if (!empty($result['jwks_uri'])) {
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
$discoveredSettings['keys'] = $this->filterKeys($keys);
@@ -175,9 +187,9 @@ class OidcProviderSettings
/**
* Get the settings needed by an OAuth provider, as a key=>value array.
*/
public function arrayForProvider(): array
public function arrayForOAuthProvider(): array
{
$settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
$settingKeys = ['clientId', 'clientSecret', 'authorizationEndpoint', 'tokenEndpoint', 'userinfoEndpoint'];
$settings = [];
foreach ($settingKeys as $setting) {
$settings[$setting] = $this->$setting;

View File

@@ -12,7 +12,6 @@ use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
@@ -91,10 +90,10 @@ class OidcService
'issuer' => $config['issuer'],
'clientId' => $config['client_id'],
'clientSecret' => $config['client_secret'],
'redirectUri' => url('/oidc/callback'),
'authorizationEndpoint' => $config['authorization_endpoint'],
'tokenEndpoint' => $config['token_endpoint'],
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
'userinfoEndpoint' => $config['userinfo_endpoint'],
]);
// Use keys if configured
@@ -129,7 +128,10 @@ class OidcService
*/
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
$provider = new OidcOAuthProvider([
...$settings->arrayForOAuthProvider(),
'redirectUri' => url('/oidc/callback'),
], [
'httpClient' => $this->http->buildClient(5),
'optionProvider' => new HttpBasicAuthOptionProvider(),
]);
@@ -156,69 +158,6 @@ class OidcService
return array_filter($scopeArr);
}
/**
* Calculate the display name.
*/
protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
{
$displayNameAttrString = $this->config()['display_name_claims'] ?? '';
$displayNameAttrs = explode('|', $displayNameAttrString);
$displayName = [];
foreach ($displayNameAttrs as $dnAttr) {
$dnComponent = $token->getClaim($dnAttr) ?? '';
if ($dnComponent !== '') {
$displayName[] = $dnComponent;
}
}
if (count($displayName) == 0) {
$displayName[] = $defaultValue;
}
return implode(' ', $displayName);
}
/**
* Extract the assigned groups from the id token.
*
* @return string[]
*/
protected function getUserGroups(OidcIdToken $token): array
{
$groupsAttr = $this->config()['groups_claim'];
if (empty($groupsAttr)) {
return [];
}
$groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
if (!is_array($groupsList)) {
return [];
}
return array_values(array_filter($groupsList, function ($val) {
return is_string($val);
}));
}
/**
* Extract the details of a user from an ID token.
*
* @return array{name: string, email: string, external_id: string, groups: string[]}
*/
protected function getUserDetails(OidcIdToken $token): array
{
$idClaim = $this->config()['external_id_claim'];
$id = $token->getClaim($idClaim);
return [
'external_id' => $id,
'email' => $token->getClaim('email'),
'name' => $this->getUserDisplayName($token, $id),
'groups' => $this->getUserGroups($token),
];
}
/**
* Processes a received access token for a user. Login the user when
* they exist, optionally registering them automatically.
@@ -255,34 +194,35 @@ class OidcService
try {
$idToken->validate($settings->clientId);
} catch (OidcInvalidTokenException $exception) {
throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
throw new OidcException("ID token validation failed with error: {$exception->getMessage()}");
}
$userDetails = $this->getUserDetails($idToken);
$isLoggedIn = auth()->check();
if (empty($userDetails['email'])) {
$userDetails = $this->getUserDetailsFromToken($idToken, $accessToken, $settings);
if (empty($userDetails->email)) {
throw new OidcException(trans('errors.oidc_no_email_address'));
}
if (empty($userDetails->name)) {
$userDetails->name = $userDetails->externalId;
}
$isLoggedIn = auth()->check();
if ($isLoggedIn) {
throw new OidcException(trans('errors.oidc_already_logged_in'));
}
try {
$user = $this->registrationService->findOrRegister(
$userDetails['name'],
$userDetails['email'],
$userDetails['external_id']
$userDetails->name,
$userDetails->email,
$userDetails->externalId
);
} catch (UserRegistrationException $exception) {
throw new OidcException($exception->getMessage());
}
if ($this->shouldSyncGroups()) {
$groups = $userDetails['groups'];
$detachExisting = $this->config()['remove_from_groups'];
$this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
$this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);
}
$this->loginService->login($user, 'oidc');
@@ -290,6 +230,45 @@ class OidcService
return $user;
}
/**
* @throws OidcException
*/
protected function getUserDetailsFromToken(OidcIdToken $idToken, OidcAccessToken $accessToken, OidcProviderSettings $settings): OidcUserDetails
{
$userDetails = new OidcUserDetails();
$userDetails->populate(
$idToken,
$this->config()['external_id_claim'],
$this->config()['display_name_claims'] ?? '',
$this->config()['groups_claim'] ?? ''
);
if (!$userDetails->isFullyPopulated($this->shouldSyncGroups()) && !empty($settings->userinfoEndpoint)) {
$provider = $this->getProvider($settings);
$request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken());
$response = new OidcUserinfoResponse(
$provider->getResponse($request),
$settings->issuer,
$settings->keys,
);
try {
$response->validate($idToken->getClaim('sub'), $settings->clientId);
} catch (OidcInvalidTokenException $exception) {
throw new OidcException("Userinfo endpoint response validation failed with error: {$exception->getMessage()}");
}
$userDetails->populate(
$response,
$this->config()['external_id_claim'],
$this->config()['display_name_claims'] ?? '',
$this->config()['groups_claim'] ?? ''
);
}
return $userDetails;
}
/**
* Get the OIDC config from the application.
*/

View File

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

View File

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

View File

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

View File

@@ -133,6 +133,7 @@ class Saml2Service
// value so that the exact encoding format is matched when checking the signature.
// This is primarily due to ADFS encoding query params with lowercase percent encoding while
// PHP (And most other sensible providers) standardise on uppercase.
/** @var ?string $samlRedirect */
$samlRedirect = $toolkit->processSLO(true, $requestId, true, null, true);
$errors = $toolkit->getErrors();

View File

@@ -0,0 +1,10 @@
<?php
namespace BookStack\Access;
use Exception;
class UserInviteException extends Exception
{
//
}

View File

@@ -13,11 +13,17 @@ class UserInviteService extends UserTokenService
/**
* Send an invitation to a user to sign into BookStack
* Removes existing invitation tokens.
* @throws UserInviteException
*/
public function sendInvitation(User $user)
{
$this->deleteByUser($user);
$token = $this->createTokenForUser($user);
$user->notify(new UserInviteNotification($token));
try {
$user->notify(new UserInviteNotification($token));
} catch (\Exception $exception) {
throw new UserInviteException($exception->getMessage(), $exception->getCode(), $exception);
}
}
}

View File

@@ -27,14 +27,14 @@ class ActivityQueries
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
->orderBy('created_at', 'desc')
->with(['user'])
->skip($count * $page)
->take($count)
->get();
$this->listLoader->loadIntoRelations($activityList->all(), 'entity', false);
$this->listLoader->loadIntoRelations($activityList->all(), 'loggable', false);
return $this->filterSimilar($activityList);
}
@@ -59,14 +59,14 @@ class ActivityQueries
$query->where(function (Builder $query) use ($queryIds) {
foreach ($queryIds as $morphClass => $idArr) {
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
$innerQuery->where('entity_type', '=', $morphClass)
->whereIn('entity_id', $idArr);
$innerQuery->where('loggable_type', '=', $morphClass)
->whereIn('loggable_id', $idArr);
});
}
});
$activity = $query->orderBy('created_at', 'desc')
->with(['entity' => function (Relation $query) {
->with(['loggable' => function (Relation $query) {
$query->withTrashed();
}, 'user.avatar'])
->skip($count * ($page - 1))
@@ -82,7 +82,7 @@ class ActivityQueries
public function userActivity(User $user, int $count = 20, int $page = 0): array
{
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)

View File

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

View File

@@ -32,7 +32,7 @@ class AuditLogController extends Controller
$query = Activity::query()
->with([
'entity' => fn ($query) => $query->withTrashed(),
'loggable' => fn ($query) => $query->withTrashed(),
'user',
])
->orderBy($listOptions->getSort(), $listOptions->getOrder());

View File

@@ -15,26 +15,24 @@ use Illuminate\Support\Str;
/**
* @property string $type
* @property User $user
* @property Entity $entity
* @property Entity $loggable
* @property string $detail
* @property string $entity_type
* @property int $entity_id
* @property string $loggable_type
* @property int $loggable_id
* @property int $user_id
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Activity extends Model
{
/**
* Get the entity for this activity.
* Get the loggable model related to this activity.
* Currently only used for entities (previously entity_[id/type] columns).
* Could be used for others but will need an audit of uses where assumed
* to be entities.
*/
public function entity(): MorphTo
public function loggable(): MorphTo
{
if ($this->entity_type === '') {
$this->entity_type = null;
}
return $this->morphTo('entity');
return $this->morphTo('loggable');
}
/**
@@ -47,8 +45,8 @@ class Activity extends Model
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
return $this->hasMany(JointPermission::class, 'entity_id', 'loggable_id')
->whereColumn('activities.loggable_type', '=', 'joint_permissions.entity_type');
}
/**
@@ -74,6 +72,6 @@ class Activity extends Model
*/
public function isSimilarTo(self $activityB): bool
{
return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
return [$this->type, $this->loggable_type, $this->loggable_id] === [$activityB->type, $activityB->loggable_type, $activityB->loggable_id];
}
}

View File

@@ -43,7 +43,7 @@ abstract class BaseActivityNotification extends MailNotification
protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine
{
return new LinkedMailMessageLine(
url('/preferences/notifications'),
url('/my-account/notifications'),
$locale->trans('notifications.footer_reason'),
$locale->trans('notifications.footer_reason_link'),
);

View File

@@ -38,7 +38,8 @@ class TagRepo
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
])
->orderBy($sort, $listOptions->getOrder());
->orderBy($sort, $listOptions->getOrder())
->whereHas('entity');
if ($nameFilter) {
$query->where('name', '=', $nameFilter);

View File

@@ -32,8 +32,8 @@ class ActivityLogger
$activity->detail = $detailToStore;
if ($detail instanceof Entity) {
$activity->entity_id = $detail->id;
$activity->entity_type = $detail->getMorphClass();
$activity->loggable_id = $detail->id;
$activity->loggable_type = $detail->getMorphClass();
}
$activity->save();
@@ -64,9 +64,9 @@ class ActivityLogger
public function removeEntity(Entity $entity): void
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
'detail' => $entity->name,
'loggable_id' => null,
'loggable_type' => null,
]);
}

View File

@@ -9,7 +9,6 @@ use BookStack\Entities\Queries\QueryRecentlyViewed;
use BookStack\Entities\Queries\QueryTopFavourites;
use BookStack\Entities\Tools\PageContent;
use BookStack\Http\Controller;
use BookStack\Uploads\FaviconHandler;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
@@ -112,48 +111,4 @@ class HomeController extends Controller
return view('home.default', $commonData);
}
/**
* Show the view for /robots.txt.
*/
public function robots()
{
$sitePublic = setting('app-public', false);
$allowRobots = config('app.allow_robots');
if ($allowRobots === null) {
$allowRobots = $sitePublic;
}
return response()
->view('misc.robots', ['allowRobots' => $allowRobots])
->header('Content-Type', 'text/plain');
}
/**
* Show the route for 404 responses.
*/
public function notFound()
{
return response()->view('errors.404', [], 404);
}
/**
* Serve the application favicon.
* Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served
* directly by the webserver in the future.
*/
public function favicon(FaviconHandler $favicons)
{
$exists = $favicons->restoreOriginalIfNotExists();
return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath());
}
/**
* Serve a PWA application manifest.
*/
public function pwaManifest(PwaManifestBuilder $manifestBuilder)
{
return response()->json($manifestBuilder->build());
}
}

View File

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

View File

@@ -25,7 +25,7 @@ class AppServiceProvider extends ServiceProvider
* Custom container bindings to register.
* @var string[]
*/
public $bindings = [
public array $bindings = [
ExceptionRenderer::class => BookStackExceptionHandlerPage::class,
];
@@ -33,7 +33,7 @@ class AppServiceProvider extends ServiceProvider
* Custom singleton bindings to register.
* @var string[]
*/
public $singletons = [
public array $singletons = [
'activity' => ActivityLogger::class,
SettingService::class => SettingService::class,
SocialDriverManager::class => SocialDriverManager::class,
@@ -42,11 +42,19 @@ class AppServiceProvider extends ServiceProvider
];
/**
* Bootstrap any application services.
*
* @return void
* Register any application services.
*/
public function boot()
public function register(): void
{
$this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null);
});
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Set root URL
$appUrl = config('app.url');
@@ -67,16 +75,4 @@ class AppServiceProvider extends ServiceProvider
'page' => Page::class,
]);
}
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null);
});
}
}

View File

@@ -18,10 +18,8 @@ class AuthServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application services.
*
* @return void
*/
public function boot()
public function boot(): void
{
// Password Configuration
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
@@ -58,10 +56,8 @@ class AuthServiceProvider extends ServiceProvider
/**
* Register the application services.
*
* @return void
*/
public function register()
public function register(): void
{
Auth::provider('external-users', function ($app, array $config) {
return new ExternalBaseUserProvider($config['model']);

View File

@@ -29,20 +29,16 @@ class EventServiceProvider extends ServiceProvider
/**
* Register any events for your application.
*
* @return void
*/
public function boot()
public function boot(): void
{
//
}
/**
* Determine if events and listeners should be automatically discovered.
*
* @return bool
*/
public function shouldDiscoverEvents()
public function shouldDiscoverEvents(): bool
{
return false;
}

View File

@@ -24,10 +24,8 @@ class RouteServiceProvider extends ServiceProvider
/**
* Define your route model bindings, pattern filters, etc.
*
* @return void
*/
public function boot()
public function boot(): void
{
$this->configureRateLimiting();
@@ -41,10 +39,8 @@ class RouteServiceProvider extends ServiceProvider
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*
* @return void
*/
protected function mapWebRoutes()
protected function mapWebRoutes(): void
{
Route::group([
'middleware' => 'web',
@@ -65,10 +61,8 @@ class RouteServiceProvider extends ServiceProvider
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*
* @return void
*/
protected function mapApiRoutes()
protected function mapApiRoutes(): void
{
Route::group([
'middleware' => 'api',
@@ -81,13 +75,15 @@ class RouteServiceProvider extends ServiceProvider
/**
* Configure the rate limiters for the application.
*
* @return void
*/
protected function configureRateLimiting()
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('public', function (Request $request) {
return Limit::perMinute(10)->by($request->ip());
});
}
}

View File

@@ -10,10 +10,8 @@ class ThemeServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/
public function register()
public function register(): void
{
// Register the ThemeService as a singleton
$this->app->singleton(ThemeService::class, fn ($app) => new ThemeService());
@@ -21,10 +19,8 @@ class ThemeServiceProvider extends ServiceProvider
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
public function boot(): void
{
// Boot up the theme system
$themeService = $this->app->make(ThemeService::class);

View File

@@ -11,10 +11,8 @@ class TranslationServiceProvider extends BaseProvider
{
/**
* Register the service provider.
*
* @return void
*/
public function register()
public function register(): void
{
$this->registerLoader();
@@ -41,10 +39,8 @@ class TranslationServiceProvider extends BaseProvider
/**
* Register the translation line loader.
* Overrides the default register action from Laravel so a custom loader can be used.
*
* @return void
*/
protected function registerLoader()
protected function registerLoader(): void
{
$this->app->singleton('translation.loader', function ($app) {
return new FileLoader($app['files'], $app['path.lang']);

View File

@@ -12,10 +12,8 @@ class ViewTweaksServiceProvider extends ServiceProvider
{
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
public function boot(): void
{
// Set paginator to use bootstrap-style pagination
Paginator::useBootstrap();

View File

@@ -9,6 +9,7 @@
*/
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider;
return [
@@ -113,46 +114,20 @@ return [
],
// Application Service Providers
'providers' => [
// Laravel Framework Service Providers...
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class,
Illuminate\Cache\CacheServiceProvider::class,
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
Illuminate\Cookie\CookieServiceProvider::class,
Illuminate\Database\DatabaseServiceProvider::class,
Illuminate\Encryption\EncryptionServiceProvider::class,
Illuminate\Filesystem\FilesystemServiceProvider::class,
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class,
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
'providers' => ServiceProvider::defaultProviders()->merge([
// Third party service providers
Barryvdh\DomPDF\ServiceProvider::class,
Barryvdh\Snappy\ServiceProvider::class,
SocialiteProviders\Manager\ServiceProvider::class,
// BookStack custom service providers
\BookStack\App\Providers\ThemeServiceProvider::class,
\BookStack\App\Providers\AppServiceProvider::class,
\BookStack\App\Providers\AuthServiceProvider::class,
\BookStack\App\Providers\EventServiceProvider::class,
\BookStack\App\Providers\RouteServiceProvider::class,
\BookStack\App\Providers\TranslationServiceProvider::class,
\BookStack\App\Providers\ValidationRuleServiceProvider::class,
\BookStack\App\Providers\ViewTweaksServiceProvider::class,
],
BookStack\App\Providers\ThemeServiceProvider::class,
BookStack\App\Providers\AppServiceProvider::class,
BookStack\App\Providers\AuthServiceProvider::class,
BookStack\App\Providers\EventServiceProvider::class,
BookStack\App\Providers\RouteServiceProvider::class,
BookStack\App\Providers\TranslationServiceProvider::class,
BookStack\App\Providers\ValidationRuleServiceProvider::class,
BookStack\App\Providers\ViewTweaksServiceProvider::class,
])->toArray(),
// Class Aliases
// This array of class aliases to be registered on application start.

View File

@@ -54,6 +54,7 @@ return [
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache'),
'lock_path' => storage_path('framework/cache'),
],
'memcached' => [

View File

@@ -1,23 +1,49 @@
<?php
/**
* DOMPDF configuration options.
* Export configuration options.
*
* Changes to these config files are not supported by BookStack and may break upon updates.
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
$snappyPaperSizeMap = [
'a4' => 'A4',
'letter' => 'Letter',
];
$dompdfPaperSizeMap = [
'a4' => 'a4',
'letter' => 'letter',
];
$exportPageSize = env('EXPORT_PAGE_SIZE', 'a4');
return [
'show_warnings' => false, // Throw an Exception on warnings from dompdf
// Set a command which can be used to convert a HTML file into a PDF file.
// When false this will not be used.
// String values represent the command to be called for conversion.
// Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
// Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
'pdf_command' => env('EXPORT_PDF_COMMAND', false),
'options' => [
// The amount of time allowed for PDF generation command to run
// before the process times out and is stopped.
'pdf_command_timeout' => env('EXPORT_PDF_COMMAND_TIMEOUT', 15),
// 2024-04: Snappy/WKHTMLtoPDF now considered deprecated in regard to BookStack support.
'snappy' => [
'pdf_binary' => env('WKHTMLTOPDF', false),
'options' => [
'print-media-type' => true,
'outline' => true,
'page-size' => $snappyPaperSizeMap[$exportPageSize] ?? 'A4',
],
],
'dompdf' => [
/**
* The location of the DOMPDF font directory.
*
@@ -101,7 +127,7 @@ return [
/**
* Whether to enable font subsetting or not.
*/
'enable_fontsubsetting' => false,
'enable_font_subsetting' => false,
/**
* The PDF rendering backend to use.
@@ -165,7 +191,7 @@ return [
*
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/
'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
'default_paper_size' => $dompdfPaperSizeMap[$exportPageSize] ?? 'a4',
/**
* The default paper orientation.
@@ -268,15 +294,6 @@ return [
*/
'font_height_ratio' => 1.1,
/**
* Enable CSS float.
*
* Allows people to disabled CSS float support
*
* @var bool
*/
'enable_css_float' => true,
/**
* Use the HTML5 Lib parser.
*
@@ -286,5 +303,4 @@ return [
*/
'enable_html5_parser' => true,
],
];

View File

@@ -21,7 +21,8 @@ return [
// passwords are hashed using the Bcrypt algorithm. This will allow you
// to control the amount of time it takes to hash the given password.
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 10),
'rounds' => env('BCRYPT_ROUNDS', 12),
'verify' => true,
],
// Argon Options

View File

@@ -4,6 +4,7 @@ use Monolog\Formatter\LineFormatter;
use Monolog\Handler\ErrorLogHandler;
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Processor\PsrLogMessageProcessor;
/**
* Logging configuration options.
@@ -49,6 +50,7 @@ return [
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 14,
'replace_placeholders' => true,
],
'daily' => [
@@ -56,6 +58,7 @@ return [
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 7,
'replace_placeholders' => true,
],
'stderr' => [
@@ -65,16 +68,20 @@ return [
'with' => [
'stream' => 'php://stderr',
],
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => 'debug',
'facility' => LOG_USER,
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => 'debug',
'replace_placeholders' => true,
],
// Custom errorlog implementation that logs out a plain,
@@ -88,6 +95,7 @@ return [
'formatter_with' => [
'format' => '%message%',
],
'replace_placeholders' => true,
],
'null' => [

View File

@@ -35,6 +35,7 @@ return [
// OAuth2 endpoints.
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
'userinfo_endpoint' => env('OIDC_USERINFO_ENDPOINT', null),
// OIDC RP-Initiated Logout endpoint URL.
// A false value force-disables RP-Initiated Logout.

View File

@@ -40,6 +40,12 @@ return [
],
// Job batching
'batching' => [
'database' => 'mysql',
'table' => 'job_batches',
],
// Failed queue job logging
'failed' => [
'driver' => 'database-uuids',

View File

@@ -123,7 +123,7 @@ return [
'dn' => env('LDAP_DN', false),
'pass' => env('LDAP_PASS', false),
'base_dn' => env('LDAP_BASE_DN', false),
'user_filter' => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
'user_filter' => env('LDAP_USER_FILTER', '(&(uid={user}))'),
'version' => env('LDAP_VERSION', false),
'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'),
'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
@@ -133,6 +133,7 @@ return [
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
'tls_insecure' => env('LDAP_TLS_INSECURE', false),
'tls_ca_cert' => env('LDAP_TLS_CA_CERT', false),
'start_tls' => env('LDAP_START_TLS', false),
'thumbnail_attribute' => env('LDAP_THUMBNAIL_ATTRIBUTE', null),
],

View File

@@ -85,4 +85,11 @@ return [
// do not enable this as other CSRF protection services are in place.
// Options: lax, strict, none
'same_site' => 'lax',
// Partitioned Cookies
// Setting this value to true will tie the cookie to the top-level site for
// a cross-site context. Partitioned cookies are accepted by the browser
// when flagged "secure" and the Same-Site attribute is set to "none".
'partitioned' => false,
];

View File

@@ -1,34 +0,0 @@
<?php
/**
* SnappyPDF configuration options.
*
* Changes to these config files are not supported by BookStack and may break upon updates.
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
$snappyPaperSizeMap = [
'a4' => 'A4',
'letter' => 'Letter',
];
return [
'pdf' => [
'enabled' => true,
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
'timeout' => false,
'options' => [
'outline' => true,
'page-size' => $snappyPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'A4',
],
'env' => [],
],
'image' => [
'enabled' => false,
'binary' => '/usr/local/bin/wkhtmltoimage',
'timeout' => false,
'options' => [],
'env' => [],
],
];

View File

@@ -19,7 +19,7 @@ class ClearActivityCommand extends Command
*
* @var string
*/
protected $description = 'Clear user activity from the system';
protected $description = 'Clear user (audit-log) activity from the system';
/**
* Execute the console command.

View File

@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Http\ApiController;
@@ -18,6 +19,7 @@ class BookApiController extends ApiController
public function __construct(
protected BookRepo $bookRepo,
protected BookQueries $queries,
protected PageQueries $pageQueries,
) {
}
@@ -69,7 +71,8 @@ class BookApiController extends ApiController
->withType()
->withField('pages', function (Entity $entity) {
if ($entity instanceof Chapter) {
return (new ApiEntityListFormatter($entity->pages->all()))->format();
$pages = $this->pageQueries->visibleForChapterList($entity->id)->get()->all();
return (new ApiEntityListFormatter($pages))->format();
}
return null;
})->format();

View File

@@ -137,7 +137,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function activity(): MorphMany
{
return $this->morphMany(Activity::class, 'entity')
return $this->morphMany(Activity::class, 'loggable')
->orderBy('created_at', 'desc');
}

View File

@@ -3,6 +3,7 @@
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditorType;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Uploads\Attachment;
use Illuminate\Database\Eloquent\Builder;

View File

@@ -11,7 +11,7 @@ use BookStack\Entities\Models\PageRevision;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditorData;
use BookStack\Entities\Tools\PageEditorType;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException;
@@ -43,6 +43,7 @@ class PageRepo
'owned_by' => user()->id,
'updated_by' => user()->id,
'draft' => true,
'editor' => PageEditorType::getSystemDefault()->value,
]);
if ($parent instanceof Chapter) {
@@ -77,7 +78,8 @@ class PageRepo
$this->updateTemplateStatusAndContentFromInput($draft, $input);
$this->baseRepo->update($draft, $input);
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
$summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
$this->revisionRepo->storeNewForPage($draft, $summary);
$draft->refresh();
Activity::add(ActivityType::PAGE_CREATE, $draft);
@@ -126,7 +128,9 @@ class PageRepo
}
$pageContent = new PageContent($page);
$currentEditor = $page->editor ?: PageEditorData::getSystemDefaultEditor();
$defaultEditor = PageEditorType::getSystemDefault();
$currentEditor = PageEditorType::forPage($page) ?: $defaultEditor;
$inputEditor = PageEditorType::fromRequestValue($input['editor'] ?? '') ?? $currentEditor;
$newEditor = $currentEditor;
$haveInput = isset($input['markdown']) || isset($input['html']);
@@ -135,15 +139,17 @@ class PageRepo
if ($haveInput && $inputEmpty) {
$pageContent->setNewHTML('', user());
} elseif (!empty($input['markdown']) && is_string($input['markdown'])) {
$newEditor = 'markdown';
$newEditor = PageEditorType::Markdown;
$pageContent->setNewMarkdown($input['markdown'], user());
} elseif (isset($input['html'])) {
$newEditor = 'wysiwyg';
$newEditor = ($inputEditor->isHtmlBased() ? $inputEditor : null) ?? ($defaultEditor->isHtmlBased() ? $defaultEditor : null) ?? PageEditorType::WysiwygTinymce;
$pageContent->setNewHTML($input['html'], user());
}
if ($newEditor !== $currentEditor && userCan('editor-change')) {
$page->editor = $newEditor;
if (($newEditor !== $currentEditor || empty($page->editor)) && userCan('editor-change')) {
$page->editor = $newEditor->value;
} elseif (empty($page->editor)) {
$page->editor = $defaultEditor->value;
}
}

View File

@@ -379,7 +379,7 @@ class PageContent
protected function headerNodesToLevelList(DOMNodeList $nodeList): array
{
$tree = collect($nodeList)->map(function (DOMElement $header) {
$text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
$text = trim(str_replace("\xc2\xa0", ' ', $header->nodeValue));
$text = mb_substr($text, 0, 100);
return [

View File

@@ -74,17 +74,17 @@ class PageEditorData
];
}
protected function updateContentForEditor(Page $page, string $editorType): void
protected function updateContentForEditor(Page $page, PageEditorType $editorType): void
{
$isHtml = !empty($page->html) && empty($page->markdown);
// HTML to markdown-clean conversion
if ($editorType === 'markdown' && $isHtml && $this->requestedEditor === 'markdown-clean') {
if ($editorType === PageEditorType::Markdown && $isHtml && $this->requestedEditor === 'markdown-clean') {
$page->markdown = (new HtmlToMarkdown($page->html))->convert();
}
// Markdown to HTML conversion if we don't have HTML
if ($editorType === 'wysiwyg' && !$isHtml) {
if ($editorType->isHtmlBased() && !$isHtml) {
$page->html = (new MarkdownToHtml($page->markdown))->convert();
}
}
@@ -94,24 +94,16 @@ class PageEditorData
* Defaults based upon the current content of the page otherwise will fall back
* to system default but will take a requested type (if provided) if permissions allow.
*/
protected function getEditorType(Page $page): string
protected function getEditorType(Page $page): PageEditorType
{
$editorType = $page->editor ?: self::getSystemDefaultEditor();
$editorType = PageEditorType::forPage($page) ?: PageEditorType::getSystemDefault();
// Use requested editor if valid and if we have permission
$requestedType = explode('-', $this->requestedEditor)[0];
if (($requestedType === 'markdown' || $requestedType === 'wysiwyg') && userCan('editor-change')) {
$requestedType = PageEditorType::fromRequestValue($this->requestedEditor);
if ($requestedType && userCan('editor-change')) {
$editorType = $requestedType;
}
return $editorType;
}
/**
* Get the configured system default editor.
*/
public static function getSystemDefaultEditor(): string
{
return setting('app-editor') === 'markdown' ? 'markdown' : 'wysiwyg';
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Page;
enum PageEditorType: string
{
case WysiwygTinymce = 'wysiwyg';
case WysiwygLexical = 'wysiwyg2024';
case Markdown = 'markdown';
public function isHtmlBased(): bool
{
return match ($this) {
self::WysiwygTinymce, self::WysiwygLexical => true,
self::Markdown => false,
};
}
public static function fromRequestValue(string $value): static|null
{
$editor = explode('-', $value)[0];
return static::tryFrom($editor);
}
public static function forPage(Page $page): static|null
{
return static::tryFrom($page->editor);
}
public static function getSystemDefault(): static
{
$setting = setting('app-editor');
return static::tryFrom($setting) ?? static::WysiwygTinymce;
}
}

View File

@@ -2,27 +2,29 @@
namespace BookStack\Entities\Tools;
use Barryvdh\DomPDF\Facade\Pdf as DomPDF;
use Barryvdh\Snappy\Facades\SnappyPdf;
use BookStack\Exceptions\PdfExportException;
use Knp\Snappy\Pdf as SnappyPdf;
use Dompdf\Dompdf;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
class PdfGenerator
{
const ENGINE_DOMPDF = 'dompdf';
const ENGINE_WKHTML = 'wkhtml';
const ENGINE_COMMAND = 'command';
/**
* Generate PDF content from the given HTML content.
* @throws PdfExportException
*/
public function fromHtml(string $html): string
{
if ($this->getActiveEngine() === self::ENGINE_WKHTML) {
$pdf = SnappyPDF::loadHTML($html);
$pdf->setOption('print-media-type', true);
} else {
$pdf = DomPDF::loadHTML($html);
}
return $pdf->output();
return match ($this->getActiveEngine()) {
self::ENGINE_COMMAND => $this->renderUsingCommand($html),
self::ENGINE_WKHTML => $this->renderUsingWkhtml($html),
default => $this->renderUsingDomPdf($html)
};
}
/**
@@ -31,8 +33,107 @@ class PdfGenerator
*/
public function getActiveEngine(): string
{
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
if (config('exports.pdf_command')) {
return self::ENGINE_COMMAND;
}
return $useWKHTML ? self::ENGINE_WKHTML : self::ENGINE_DOMPDF;
if ($this->getWkhtmlBinaryPath() && config('app.allow_untrusted_server_fetching') === true) {
return self::ENGINE_WKHTML;
}
return self::ENGINE_DOMPDF;
}
protected function getWkhtmlBinaryPath(): string
{
$wkhtmlBinaryPath = config('exports.snappy.pdf_binary');
if (file_exists(base_path('wkhtmltopdf'))) {
$wkhtmlBinaryPath = base_path('wkhtmltopdf');
}
return $wkhtmlBinaryPath ?: '';
}
protected function renderUsingDomPdf(string $html): string
{
$options = config('exports.dompdf');
$domPdf = new Dompdf($options);
$domPdf->setBasePath(base_path('public'));
$domPdf->loadHTML($this->convertEntities($html));
$domPdf->render();
return (string) $domPdf->output();
}
/**
* @throws PdfExportException
*/
protected function renderUsingCommand(string $html): string
{
$command = config('exports.pdf_command');
$inputHtml = tempnam(sys_get_temp_dir(), 'bs-pdfgen-html-');
$outputPdf = tempnam(sys_get_temp_dir(), 'bs-pdfgen-output-');
$replacementsByPlaceholder = [
'{input_html_path}' => $inputHtml,
'{output_pdf_path}' => $outputPdf,
];
foreach ($replacementsByPlaceholder as $placeholder => $replacement) {
$command = str_replace($placeholder, escapeshellarg($replacement), $command);
}
file_put_contents($inputHtml, $html);
$timeout = intval(config('exports.pdf_command_timeout'));
$process = Process::fromShellCommandline($command);
$process->setTimeout($timeout);
try {
$process->run();
} catch (ProcessTimedOutException $e) {
throw new PdfExportException("PDF Export via command failed due to timeout at {$timeout} second(s)");
}
if (!$process->isSuccessful()) {
throw new PdfExportException("PDF Export via command failed with exit code {$process->getExitCode()}, stdout: {$process->getOutput()}, stderr: {$process->getErrorOutput()}");
}
$pdfContents = file_get_contents($outputPdf);
unlink($outputPdf);
if ($pdfContents === false) {
throw new PdfExportException("PDF Export via command failed, unable to read PDF output file");
} else if (empty($pdfContents)) {
throw new PdfExportException("PDF Export via command failed, PDF output file is empty");
}
return $pdfContents;
}
protected function renderUsingWkhtml(string $html): string
{
$snappy = new SnappyPdf($this->getWkhtmlBinaryPath());
$options = config('exports.snappy.options');
return $snappy->getOutputFromHtml($html, $options);
}
/**
* Taken from https://github.com/barryvdh/laravel-dompdf/blob/v2.1.1/src/PDF.php
* Copyright (c) 2021 barryvdh, MIT License
* https://github.com/barryvdh/laravel-dompdf/blob/v2.1.1/LICENSE
*/
protected function convertEntities(string $subject): string
{
$entities = [
'€' => '&euro;',
'£' => '&pound;',
];
foreach ($entities as $search => $replace) {
$subject = str_replace($search, $replace, $subject);
}
return $subject;
}
}

View File

@@ -43,13 +43,13 @@ class SiblingFetcher
if ($contextShelf) {
$entities = $contextShelf->visibleBooks()->get();
} else {
$entities = $this->queries->books->visibleForList()->get();
$entities = $this->queries->books->visibleForList()->orderBy('name', 'asc')->get();
}
}
// Shelf
if ($entity instanceof Bookshelf) {
$entities = $this->queries->shelves->visibleForList()->get();
$entities = $this->queries->shelves->visibleForList()->orderBy('name', 'asc')->get();
}
return $entities;

View File

@@ -0,0 +1,7 @@
<?php
namespace BookStack\Exceptions;
class PdfExportException extends \Exception
{
}

View File

@@ -9,16 +9,10 @@ use Illuminate\Http\Request;
class StoppedAuthenticationException extends \Exception implements Responsable
{
protected $user;
protected $loginService;
/**
* StoppedAuthenticationException constructor.
*/
public function __construct(User $user, LoginService $loginService)
{
$this->user = $user;
$this->loginService = $loginService;
public function __construct(
protected User $user,
protected LoginService $loginService
) {
parent::__construct();
}

View File

@@ -45,11 +45,11 @@ class Kernel extends HttpKernel
];
/**
* The application's route middleware.
* The application's middleware aliases.
*
* @var array
*/
protected $routeMiddleware = [
protected $middlewareAliases = [
'auth' => \BookStack\Http\Middleware\Authenticate::class,
'can' => \BookStack\Http\Middleware\CheckUserHasPermission::class,
'guest' => \BookStack\Http\Middleware\RedirectIfAuthenticated::class,

View File

@@ -6,19 +6,16 @@ use BookStack\App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null ...$guards
*
* @return mixed
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next, ...$guards)
public function handle(Request $request, Closure $next, string ...$guards): Response
{
$guards = empty($guards) ? [null] : $guards;

View File

@@ -9,7 +9,7 @@ class ThrottleApiRequests extends Middleware
/**
* Resolve the number of attempts if the user is authenticated or not.
*/
protected function resolveMaxAttempts($request, $maxAttempts)
protected function resolveMaxAttempts($request, $maxAttempts): int
{
return (int) config('api.requests_per_minute');
}

View File

@@ -9,9 +9,9 @@ class TrustHosts extends Middleware
/**
* Get the host patterns that should be trusted.
*
* @return array
* @return array<int, string|null>
*/
public function hosts()
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),

View File

@@ -41,7 +41,8 @@ class ReferenceFetcher
{
$baseQuery = Reference::query()
->where('to_type', '=', $entity->getMorphClass())
->where('to_id', '=', $entity->id);
->where('to_id', '=', $entity->id)
->whereHas('from');
return $this->permissions->restrictEntityRelationQuery(
$baseQuery,

View File

@@ -0,0 +1,13 @@
<?php
namespace BookStack\Search\Options;
class ExactSearchOption extends SearchOption
{
public function toString(): string
{
$escaped = str_replace('\\', '\\\\', $this->value);
$escaped = str_replace('"', '\"', $escaped);
return ($this->negated ? '-' : '') . '"' . $escaped . '"';
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace BookStack\Search\Options;
class FilterSearchOption extends SearchOption
{
protected string $name;
public function __construct(
string $value,
string $name,
bool $negated = false,
) {
parent::__construct($value, $negated);
$this->name = $name;
}
public function toString(): string
{
$valueText = ($this->value ? ':' . $this->value : '');
$filterBrace = '{' . $this->name . $valueText . '}';
return ($this->negated ? '-' : '') . $filterBrace;
}
public function getKey(): string
{
return $this->name;
}
public static function fromContentString(string $value, bool $negated = false): self
{
$explodedFilter = explode(':', $value, 2);
$filterValue = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
$filterName = $explodedFilter[0];
return new self($filterValue, $filterName, $negated);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace BookStack\Search\Options;
abstract class SearchOption
{
public function __construct(
public string $value,
public bool $negated = false,
) {
}
/**
* Get the key used for this option when used in a map.
* Null indicates to use the index of the containing array.
*/
public function getKey(): string|null
{
return null;
}
/**
* Get the search string representation for this search option.
*/
abstract public function toString(): string;
}

View File

@@ -0,0 +1,37 @@
<?php
namespace BookStack\Search\Options;
class TagSearchOption extends SearchOption
{
/**
* Acceptable operators to be used within a tag search option.
*
* @var string[]
*/
protected array $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
public function toString(): string
{
return ($this->negated ? '-' : '') . "[{$this->value}]";
}
/**
* @return array{name: string, operator: string, value: string}
*/
public function getParts(): array
{
$operatorRegex = implode('|', array_map(fn($op) => preg_quote($op), $this->queryOperators));
preg_match('/^(.*?)((' . $operatorRegex . ')(.*?))?$/', $this->value, $tagSplit);
$extractedOperator = count($tagSplit) > 2 ? $tagSplit[3] : '';
$tagOperator = in_array($extractedOperator, $this->queryOperators) ? $extractedOperator : '=';
$tagValue = count($tagSplit) > 3 ? $tagSplit[4] : '';
return [
'name' => $tagSplit[1],
'operator' => $tagOperator,
'value' => $tagValue,
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace BookStack\Search\Options;
class TermSearchOption extends SearchOption
{
public function toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace BookStack\Search;
use BookStack\Search\Options\SearchOption;
/**
* @template T of SearchOption
*/
class SearchOptionSet
{
/**
* @var T[]
*/
protected array $options = [];
public function __construct(array $options = [])
{
$this->options = $options;
}
public function toValueArray(): array
{
return array_map(fn(SearchOption $option) => $option->value, $this->options);
}
public function toValueMap(): array
{
$map = [];
foreach ($this->options as $index => $option) {
$key = $option->getKey() ?? $index;
$map[$key] = $option->value;
}
return $map;
}
public function merge(SearchOptionSet $set): self
{
return new self(array_merge($this->options, $set->options));
}
public function filterEmpty(): self
{
$filteredOptions = array_values(array_filter($this->options, fn (SearchOption $option) => !empty($option->value)));
return new self($filteredOptions);
}
/**
* @param class-string<SearchOption> $class
*/
public static function fromValueArray(array $values, string $class): self
{
$options = array_map(fn($val) => new $class($val), $values);
return new self($options);
}
/**
* @return T[]
*/
public function all(): array
{
return $this->options;
}
/**
* @return self<T>
*/
public function negated(): self
{
$values = array_values(array_filter($this->options, fn (SearchOption $option) => $option->negated));
return new self($values);
}
/**
* @return self<T>
*/
public function nonNegated(): self
{
$values = array_values(array_filter($this->options, fn (SearchOption $option) => !$option->negated));
return new self($values);
}
}

View File

@@ -2,26 +2,39 @@
namespace BookStack\Search;
use BookStack\Search\Options\ExactSearchOption;
use BookStack\Search\Options\FilterSearchOption;
use BookStack\Search\Options\SearchOption;
use BookStack\Search\Options\TagSearchOption;
use BookStack\Search\Options\TermSearchOption;
use Illuminate\Http\Request;
class SearchOptions
{
public array $searches = [];
public array $exacts = [];
public array $tags = [];
public array $filters = [];
/** @var SearchOptionSet<TermSearchOption> */
public SearchOptionSet $searches;
/** @var SearchOptionSet<ExactSearchOption> */
public SearchOptionSet $exacts;
/** @var SearchOptionSet<TagSearchOption> */
public SearchOptionSet $tags;
/** @var SearchOptionSet<FilterSearchOption> */
public SearchOptionSet $filters;
public function __construct()
{
$this->searches = new SearchOptionSet();
$this->exacts = new SearchOptionSet();
$this->tags = new SearchOptionSet();
$this->filters = new SearchOptionSet();
}
/**
* Create a new instance from a search string.
*/
public static function fromString(string $search): self
{
$decoded = static::decode($search);
$instance = new SearchOptions();
foreach ($decoded as $type => $value) {
$instance->$type = $value;
}
$instance = new self();
$instance->addOptionsFromString($search);
return $instance;
}
@@ -41,46 +54,64 @@ class SearchOptions
}
$instance = new SearchOptions();
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags', 'extras']);
$parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
$instance->searches = array_filter($parsedStandardTerms['terms']);
$instance->exacts = array_filter($parsedStandardTerms['exacts']);
array_push($instance->exacts, ...array_filter($inputs['exact'] ?? []));
$instance->tags = array_filter($inputs['tags'] ?? []);
$inputExacts = array_filter($inputs['exact'] ?? []);
$instance->searches = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['terms']), TermSearchOption::class);
$instance->exacts = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['exacts']), ExactSearchOption::class);
$instance->exacts = $instance->exacts->merge(SearchOptionSet::fromValueArray($inputExacts, ExactSearchOption::class));
$instance->tags = SearchOptionSet::fromValueArray(array_filter($inputs['tags'] ?? []), TagSearchOption::class);
$cleanedFilters = [];
foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
if (empty($filterVal)) {
continue;
}
$instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal;
$cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal;
$cleanedFilters[] = new FilterSearchOption($cleanedFilterVal, $filterKey);
}
if (isset($inputs['types']) && count($inputs['types']) < 4) {
$instance->filters['type'] = implode('|', $inputs['types']);
$cleanedFilters[] = new FilterSearchOption(implode('|', $inputs['types']), 'type');
}
$instance->filters = new SearchOptionSet($cleanedFilters);
// Parse and merge in extras if provided
if (!empty($inputs['extras'])) {
$extras = static::fromString($inputs['extras']);
$instance->searches = $instance->searches->merge($extras->searches);
$instance->exacts = $instance->exacts->merge($extras->exacts);
$instance->tags = $instance->tags->merge($extras->tags);
$instance->filters = $instance->filters->merge($extras->filters);
}
return $instance;
}
/**
* Decode a search string into an array of terms.
* Decode a search string and add its contents to this instance.
*/
protected static function decode(string $searchString): array
protected function addOptionsFromString(string $searchString): void
{
/** @var array<string, SearchOption[]> $terms */
$terms = [
'searches' => [],
'exacts' => [],
'tags' => [],
'filters' => [],
];
$patterns = [
'exacts' => '/"((?:\\\\.|[^"\\\\])*)"/',
'tags' => '/\[(.*?)\]/',
'filters' => '/\{(.*?)\}/',
'exacts' => '/-?"((?:\\\\.|[^"\\\\])*)"/',
'tags' => '/-?\[(.*?)\]/',
'filters' => '/-?\{(.*?)\}/',
];
$constructors = [
'exacts' => fn(string $value, bool $negated) => new ExactSearchOption($value, $negated),
'tags' => fn(string $value, bool $negated) => new TagSearchOption($value, $negated),
'filters' => fn(string $value, bool $negated) => FilterSearchOption::fromContentString($value, $negated),
];
// Parse special terms
@@ -88,34 +119,32 @@ class SearchOptions
$matches = [];
preg_match_all($pattern, $searchString, $matches);
if (count($matches) > 0) {
$terms[$termType] = $matches[1];
foreach ($matches[1] as $index => $value) {
$negated = str_starts_with($matches[0][$index], '-');
$terms[$termType][] = $constructors[$termType]($value, $negated);
}
$searchString = preg_replace($pattern, '', $searchString);
}
}
// Unescape exacts and backslash escapes
foreach ($terms['exacts'] as $index => $exact) {
$terms['exacts'][$index] = static::decodeEscapes($exact);
foreach ($terms['exacts'] as $exact) {
$exact->value = static::decodeEscapes($exact->value);
}
// Parse standard terms
$parsedStandardTerms = static::parseStandardTermString($searchString);
array_push($terms['searches'], ...$parsedStandardTerms['terms']);
array_push($terms['exacts'], ...$parsedStandardTerms['exacts']);
$this->searches = $this->searches
->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms'], TermSearchOption::class))
->filterEmpty();
$this->exacts = $this->exacts
->merge(new SearchOptionSet($terms['exacts']))
->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts'], ExactSearchOption::class))
->filterEmpty();
// Split filter values out
$splitFilters = [];
foreach ($terms['filters'] as $filter) {
$explodedFilter = explode(':', $filter, 2);
$splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
}
$terms['filters'] = $splitFilters;
// Filter down terms where required
$terms['exacts'] = array_filter($terms['exacts']);
$terms['searches'] = array_filter($terms['searches']);
return $terms;
// Add tags & filters
$this->tags = $this->tags->merge(new SearchOptionSet($terms['tags']));
$this->filters = $this->filters->merge(new SearchOptionSet($terms['filters']));
}
/**
@@ -175,7 +204,9 @@ class SearchOptions
*/
public function setFilter(string $filterName, string $filterValue = ''): void
{
$this->filters[$filterName] = $filterValue;
$this->filters = $this->filters->merge(
new SearchOptionSet([new FilterSearchOption($filterValue, $filterName)])
);
}
/**
@@ -183,22 +214,43 @@ class SearchOptions
*/
public function toString(): string
{
$parts = $this->searches;
$options = [
...$this->searches->all(),
...$this->exacts->all(),
...$this->tags->all(),
...$this->filters->all(),
];
foreach ($this->exacts as $term) {
$escaped = str_replace('\\', '\\\\', $term);
$escaped = str_replace('"', '\"', $escaped);
$parts[] = '"' . $escaped . '"';
}
foreach ($this->tags as $term) {
$parts[] = "[{$term}]";
}
foreach ($this->filters as $filterName => $filterVal) {
$parts[] = '{' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
}
$parts = array_map(fn(SearchOption $o) => $o->toString(), $options);
return implode(' ', $parts);
}
/**
* Get the search options that don't have UI controls provided for.
* Provided back as a key => value array with the keys being expected
* input names for a search form, and values being the option value.
*/
public function getAdditionalOptionsString(): string
{
$options = [];
// Handle filters without UI support
$userFilters = ['updated_by', 'created_by', 'owned_by'];
$unsupportedFilters = ['is_template', 'sort_by'];
foreach ($this->filters->all() as $filter) {
if (in_array($filter->getKey(), $userFilters, true) && $filter->value !== null && $filter->value !== 'me') {
$options[] = $filter;
} else if (in_array($filter->getKey(), $unsupportedFilters, true)) {
$options[] = $filter;
}
}
// Negated items
array_push($options, ...$this->exacts->negated()->all());
array_push($options, ...$this->tags->negated()->all());
array_push($options, ...$this->filters->negated()->all());
return implode(' ', array_map(fn(SearchOption $o) => $o->toString(), $options));
}
}

View File

@@ -25,11 +25,12 @@ class SearchResultsFormatter
* Update the given entity model to set attributes used for previews of the item
* primarily within search result lists.
*/
protected function setSearchPreview(Entity $entity, SearchOptions $options)
protected function setSearchPreview(Entity $entity, SearchOptions $options): void
{
$textProperty = $entity->textField;
$textContent = $entity->$textProperty;
$terms = array_merge($options->exacts, $options->searches);
$relevantSearchOptions = $options->exacts->merge($options->searches);
$terms = $relevantSearchOptions->toValueArray();
$originalContentByNewAttribute = [
'preview_name' => $entity->name,

View File

@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Search\Options\TagSearchOption;
use BookStack\Users\Models\User;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
@@ -16,31 +17,21 @@ use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use SplObjectStorage;
use WeakMap;
class SearchRunner
{
/**
* Acceptable operators to be used in a query.
*
* @var string[]
*/
protected array $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
/**
* Retain a cache of score adjusted terms for specific search options.
* From PHP>=8 this can be made into a WeakMap instead.
*
* @var SplObjectStorage
*/
protected $termAdjustmentCache;
protected WeakMap $termAdjustmentCache;
public function __construct(
protected EntityProvider $entityProvider,
protected PermissionApplicator $permissions,
protected EntityQueries $entityQueries,
) {
$this->termAdjustmentCache = new SplObjectStorage();
$this->termAdjustmentCache = new WeakMap();
}
/**
@@ -55,10 +46,11 @@ class SearchRunner
$entityTypes = array_keys($this->entityProvider->all());
$entityTypesToSearch = $entityTypes;
$filterMap = $searchOpts->filters->toValueMap();
if ($entityType !== 'all') {
$entityTypesToSearch = [$entityType];
} elseif (isset($searchOpts->filters['type'])) {
$entityTypesToSearch = explode('|', $searchOpts->filters['type']);
} elseif (isset($filterMap['type'])) {
$entityTypesToSearch = explode('|', $filterMap['type']);
}
$results = collect();
@@ -97,7 +89,8 @@ class SearchRunner
{
$opts = SearchOptions::fromString($searchString);
$entityTypes = ['page', 'chapter'];
$entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->filters['type']) : $entityTypes;
$filterMap = $opts->filters->toValueMap();
$entityTypesToSearch = isset($filterMap['type']) ? explode('|', $filterMap['type']) : $entityTypes;
$results = collect();
foreach ($entityTypesToSearch as $entityType) {
@@ -161,24 +154,26 @@ class SearchRunner
$this->applyTermSearch($entityQuery, $searchOpts, $entityType);
// Handle exact term matching
foreach ($searchOpts->exacts as $inputTerm) {
$entityQuery->where(function (EloquentBuilder $query) use ($inputTerm, $entityModelInstance) {
$inputTerm = str_replace('\\', '\\\\', $inputTerm);
foreach ($searchOpts->exacts->all() as $exact) {
$filter = function (EloquentBuilder $query) use ($exact, $entityModelInstance) {
$inputTerm = str_replace('\\', '\\\\', $exact->value);
$query->where('name', 'like', '%' . $inputTerm . '%')
->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%');
});
};
$exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter);
}
// Handle tag searches
foreach ($searchOpts->tags as $inputTerm) {
$this->applyTagSearch($entityQuery, $inputTerm);
foreach ($searchOpts->tags->all() as $tagOption) {
$this->applyTagSearch($entityQuery, $tagOption);
}
// Handle filters
foreach ($searchOpts->filters as $filterTerm => $filterValue) {
$functionName = Str::camel('filter_' . $filterTerm);
foreach ($searchOpts->filters->all() as $filterOption) {
$functionName = Str::camel('filter_' . $filterOption->getKey());
if (method_exists($this, $functionName)) {
$this->$functionName($entityQuery, $entityModelInstance, $filterValue);
$this->$functionName($entityQuery, $entityModelInstance, $filterOption->value, $filterOption->negated);
}
}
@@ -190,7 +185,7 @@ class SearchRunner
*/
protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, string $entityType): void
{
$terms = $options->searches;
$terms = $options->searches->toValueArray();
if (count($terms) === 0) {
return;
}
@@ -209,8 +204,8 @@ class SearchRunner
$subQuery->where('entity_type', '=', $entityType);
$subQuery->where(function (Builder $query) use ($terms) {
foreach ($terms as $inputTerm) {
$inputTerm = str_replace('\\', '\\\\', $inputTerm);
$query->orWhere('term', 'like', $inputTerm . '%');
$escapedTerm = str_replace('\\', '\\\\', $inputTerm);
$query->orWhere('term', 'like', $escapedTerm . '%');
}
});
$subQuery->groupBy('entity_type', 'entity_id');
@@ -264,7 +259,7 @@ class SearchRunner
$whenStatements = [];
$whenBindings = [];
foreach ($options->searches as $term) {
foreach ($options->searches->toValueArray() as $term) {
$whenStatements[] = 'WHEN term LIKE ? THEN ?';
$whenBindings[] = $term . '%';
$whenBindings[] = $term;
@@ -310,179 +305,165 @@ class SearchRunner
}
/**
* Get the available query operators as a regex escaped list.
* Apply a tag search term onto an entity query.
*/
protected function getRegexEscapedOperators(): string
protected function applyTagSearch(EloquentBuilder $query, TagSearchOption $option): void
{
$escapedOperators = [];
foreach ($this->queryOperators as $operator) {
$escapedOperators[] = preg_quote($operator);
}
$filter = function (EloquentBuilder $query) use ($option): void {
$tagParts = $option->getParts();
if (empty($tagParts['operator']) || empty($tagParts['value'])) {
$query->where('name', '=', $tagParts['name']);
return;
}
return implode('|', $escapedOperators);
if (!empty($tagParts['name'])) {
$query->where('name', '=', $tagParts['name']);
}
if (is_numeric($tagParts['value']) && $tagParts['operator'] !== 'like') {
// We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
// search the value as a string which prevents being able to do number-based operations
// on the tag values. We ensure it has a numeric value and then cast it just to be sure.
/** @var Connection $connection */
$connection = $query->getConnection();
$quotedValue = (float) trim($connection->getPdo()->quote($tagParts['value']), "'");
$query->whereRaw("value {$tagParts['operator']} {$quotedValue}");
} else if ($tagParts['operator'] === 'like') {
$query->where('value', $tagParts['operator'], str_replace('\\', '\\\\', $tagParts['value']));
} else {
$query->where('value', $tagParts['operator'], $tagParts['value']);
}
};
$option->negated ? $query->whereDoesntHave('tags', $filter) : $query->whereHas('tags', $filter);
}
/**
* Apply a tag search term onto a entity query.
*/
protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string $column, string $operator, mixed $value): void
{
preg_match('/^(.*?)((' . $this->getRegexEscapedOperators() . ')(.*?))?$/', $tagTerm, $tagSplit);
$query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
$tagName = $tagSplit[1];
$tagOperator = count($tagSplit) > 2 ? $tagSplit[3] : '';
$tagValue = count($tagSplit) > 3 ? $tagSplit[4] : '';
$validOperator = in_array($tagOperator, $this->queryOperators);
if (!empty($tagOperator) && !empty($tagValue) && $validOperator) {
if (!empty($tagName)) {
$query->where('name', '=', $tagName);
}
if (is_numeric($tagValue) && $tagOperator !== 'like') {
// We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
// search the value as a string which prevents being able to do number-based operations
// on the tag values. We ensure it has a numeric value and then cast it just to be sure.
/** @var Connection $connection */
$connection = $query->getConnection();
$tagValue = (float) trim($connection->getPdo()->quote($tagValue), "'");
$query->whereRaw("value {$tagOperator} {$tagValue}");
} else {
if ($tagOperator === 'like') {
$tagValue = str_replace('\\', '\\\\', $tagValue);
}
$query->where('value', $tagOperator, $tagValue);
}
} else {
$query->where('name', '=', $tagName);
}
});
return $query;
if ($negated) {
$query->whereNot($column, $operator, $value);
} else {
$query->where($column, $operator, $value);
}
}
/**
* Custom entity search filters.
*/
protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input): void
protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
{
try {
$date = date_create($input);
$query->where('updated_at', '>=', $date);
} catch (\Exception $e) {
}
$date = date_create($input);
$this->applyNegatableWhere($query, $negated, 'updated_at', '>=', $date);
}
protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input): void
protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
{
try {
$date = date_create($input);
$query->where('updated_at', '<', $date);
} catch (\Exception $e) {
}
$date = date_create($input);
$this->applyNegatableWhere($query, $negated, 'updated_at', '<', $date);
}
protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input): void
protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
{
try {
$date = date_create($input);
$query->where('created_at', '>=', $date);
} catch (\Exception $e) {
}
$date = date_create($input);
$this->applyNegatableWhere($query, $negated, 'created_at', '>=', $date);
}
protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, $input)
protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{
try {
$date = date_create($input);
$query->where('created_at', '<', $date);
} catch (\Exception $e) {
}
$date = date_create($input);
$this->applyNegatableWhere($query, $negated, 'created_at', '<', $date);
}
protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input)
protected function filterCreatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{
$userSlug = $input === 'me' ? user()->slug : trim($input);
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
if ($user) {
$query->where('created_by', '=', $user->id);
$this->applyNegatableWhere($query, $negated, 'created_by', '=', $user->id);
}
}
protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, $input)
protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{
$userSlug = $input === 'me' ? user()->slug : trim($input);
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
if ($user) {
$query->where('updated_by', '=', $user->id);
$this->applyNegatableWhere($query, $negated, 'updated_by', '=', $user->id);
}
}
protected function filterOwnedBy(EloquentBuilder $query, Entity $model, $input)
protected function filterOwnedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{
$userSlug = $input === 'me' ? user()->slug : trim($input);
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
if ($user) {
$query->where('owned_by', '=', $user->id);
$this->applyNegatableWhere($query, $negated, 'owned_by', '=', $user->id);
}
}
protected function filterInName(EloquentBuilder $query, Entity $model, $input)
protected function filterInName(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{
$query->where('name', 'like', '%' . $input . '%');
$this->applyNegatableWhere($query, $negated, 'name', 'like', '%' . $input . '%');
}
protected function filterInTitle(EloquentBuilder $query, Entity $model, $input)
protected function filterInTitle(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{
$this->filterInName($query, $model, $input);
$this->filterInName($query, $model, $input, $negated);
}
protected function filterInBody(EloquentBuilder $query, Entity $model, $input)
protected function filterInBody(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{
$query->where($model->textField, 'like', '%' . $input . '%');
$this->applyNegatableWhere($query, $negated, $model->textField, 'like', '%' . $input . '%');
}
protected function filterIsRestricted(EloquentBuilder $query, Entity $model, $input)
protected function filterIsRestricted(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{
$query->whereHas('permissions');
$negated ? $query->whereDoesntHave('permissions') : $query->whereHas('permissions');
}
protected function filterViewedByMe(EloquentBuilder $query, Entity $model, $input)
protected function filterViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{
$query->whereHas('views', function ($query) {
$filter = function ($query) {
$query->where('user_id', '=', user()->id);
});
};
$negated ? $query->whereDoesntHave('views', $filter) : $query->whereHas('views', $filter);
}
protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, $input)
protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{
$query->whereDoesntHave('views', function ($query) {
$filter = function ($query) {
$query->where('user_id', '=', user()->id);
});
};
$negated ? $query->whereHas('views', $filter) : $query->whereDoesntHave('views', $filter);
}
protected function filterIsTemplate(EloquentBuilder $query, Entity $model, $input)
protected function filterIsTemplate(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{
if ($model instanceof Page) {
$query->where('template', '=', true);
$this->applyNegatableWhere($query, $negated, 'template', '=', true);
}
}
protected function filterSortBy(EloquentBuilder $query, Entity $model, $input)
protected function filterSortBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{
$functionName = Str::camel('sort_by_' . $input);
if (method_exists($this, $functionName)) {
$this->$functionName($query, $model);
$this->$functionName($query, $model, $negated);
}
}
/**
* Sorting filter options.
*/
protected function sortByLastCommented(EloquentBuilder $query, Entity $model)
protected function sortByLastCommented(EloquentBuilder $query, Entity $model, bool $negated)
{
$commentsTable = DB::getTablePrefix() . 'comments';
$morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
$commentQuery = DB::raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments');
$query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc');
$query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.entity_id'))
->orderBy('last_commented', $negated ? 'asc' : 'desc');
}
}

View File

@@ -8,16 +8,22 @@ class FileLoader extends BaseLoader
{
/**
* Load the messages for the given locale.
*
* Extends Laravel's translation FileLoader to look in multiple directories
* so that we can load in translation overrides from the theme file if wanted.
*
* Note: As of using Laravel 10, this may now be redundant since Laravel's
* file loader supports multiple paths. This needs further testing though
* to confirm if Laravel works how we expect, since we specifically need
* the theme folder to be able to partially override core lang files.
*
* @param string $locale
* @param string $group
* @param string|null $namespace
*
* @return array
*/
public function load($locale, $group, $namespace = null)
public function load($locale, $group, $namespace = null): array
{
if ($group === '*' && $namespace === '*') {
return $this->loadJsonPaths($locale);
@@ -25,8 +31,8 @@ class FileLoader extends BaseLoader
if (is_null($namespace) || $namespace === '*') {
$themePath = theme_path('lang');
$themeTranslations = $themePath ? $this->loadPath($themePath, $locale, $group) : [];
$originalTranslations = $this->loadPath($this->path, $locale, $group);
$themeTranslations = $themePath ? $this->loadPaths([$themePath], $locale, $group) : [];
$originalTranslations = $this->loadPaths($this->paths, $locale, $group);
return array_merge($originalTranslations, $themeTranslations);
}

View File

@@ -171,16 +171,16 @@ class AttachmentApiController extends ApiController
{
return [
'create' => [
'name' => ['required', 'min:1', 'max:255', 'string'],
'name' => ['required', 'string', 'min:1', 'max:255'],
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
'link' => ['required_without:file', 'min:1', 'max:2000', 'safe_url'],
'link' => ['required_without:file', 'string', 'min:1', 'max:2000', 'safe_url'],
],
'update' => [
'name' => ['min:1', 'max:255', 'string'],
'name' => ['string', 'min:1', 'max:255'],
'uploaded_to' => ['integer', 'exists:pages,id'],
'file' => $this->attachmentService->getFileValidationRules(),
'link' => ['min:1', 'max:2000', 'safe_url'],
'link' => ['string', 'min:1', 'max:2000', 'safe_url'],
],
];
}

View File

@@ -166,7 +166,7 @@ class ImageRepo
*/
public function updateImageFile(Image $image, UploadedFile $file): void
{
if ($file->getClientOriginalExtension() !== pathinfo($image->path, PATHINFO_EXTENSION)) {
if (strtolower($file->getClientOriginalExtension()) !== strtolower(pathinfo($image->path, PATHINFO_EXTENSION))) {
throw new ImageUploadException(trans('errors.image_upload_replace_type'));
}

View File

@@ -6,8 +6,14 @@ use BookStack\Exceptions\ImageUploadException;
use Exception;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Support\Facades\Cache;
use Intervention\Image\Gd\Driver;
use Intervention\Image\Image as InterventionImage;
use Intervention\Image\Decoders\BinaryImageDecoder;
use Intervention\Image\Drivers\Gd\Decoders\NativeObjectDecoder;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\Encoders\AutoEncoder;
use Intervention\Image\Encoders\PngEncoder;
use Intervention\Image\Interfaces\ImageInterface as InterventionImage;
use Intervention\Image\ImageManager;
use Intervention\Image\Origin;
class ImageResizer
{
@@ -95,7 +101,7 @@ class ImageResizer
}
// If not in cache and thumbnail does not exist, generate thumb and cache path
$thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio);
$thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio, $this->getExtension($image));
$disk->put($thumbFilePath, $thumbData, true);
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
@@ -116,7 +122,7 @@ class ImageResizer
?string $format = null,
): string {
try {
$thumb = $this->interventionFromImageData($imageData);
$thumb = $this->interventionFromImageData($imageData, $format);
} catch (Exception $e) {
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
}
@@ -124,15 +130,17 @@ class ImageResizer
$this->orientImageToOriginalExif($thumb, $imageData);
if ($keepRatio) {
$thumb->resize($width, $height, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
$thumb->scaleDown($width, $height);
} else {
$thumb->fit($width, $height);
$thumb->cover($width, $height);
}
$thumbData = (string) $thumb->encode($format);
$encoder = match ($format) {
'png' => new PngEncoder(),
default => new AutoEncoder(),
};
$thumbData = (string) $thumb->encode($encoder);
// Use original image data if we're keeping the ratio
// and the resizing does not save any space.
@@ -148,10 +156,23 @@ class ImageResizer
* Performs some manual library usage to ensure image is specifically loaded
* from given binary data instead of data being misinterpreted.
*/
protected function interventionFromImageData(string $imageData): InterventionImage
protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage
{
$driver = new Driver();
return $driver->decoder->initFromBinary($imageData);
$manager = new ImageManager(new Driver());
// Ensure gif images are decoded natively instead of deferring to intervention GIF
// handling since we don't need the added animation support.
$isGif = $fileType === 'gif';
$decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class;
$input = $isGif ? @imagecreatefromstring($imageData) : $imageData;
$image = $manager->read($input, $decoder);
if ($isGif) {
$image->setOrigin(new Origin('image/gif'));
}
return $image;
}
/**
@@ -202,7 +223,15 @@ class ImageResizer
*/
protected function isGif(Image $image): bool
{
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
return $this->getExtension($image) === 'gif';
}
/**
* Get the extension for the given image, normalised to lower-case.
*/
protected function getExtension(Image $image): string
{
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION));
}
/**

View File

@@ -21,7 +21,7 @@ class RoleApiController extends ApiController
'display_name' => ['required', 'string', 'min:3', 'max:180'],
'description' => ['string', 'max:180'],
'mfa_enforced' => ['boolean'],
'external_auth_id' => ['string'],
'external_auth_id' => ['string', 'max:180'],
'permissions' => ['array'],
'permissions.*' => ['string'],
],
@@ -29,7 +29,7 @@ class RoleApiController extends ApiController
'display_name' => ['string', 'min:3', 'max:180'],
'description' => ['string', 'max:180'],
'mfa_enforced' => ['boolean'],
'external_auth_id' => ['string'],
'external_auth_id' => ['string', 'max:180'],
'permissions' => ['array'],
'permissions.*' => ['string'],
]

View File

@@ -75,7 +75,7 @@ class RoleController extends Controller
$data = $this->validate($request, [
'display_name' => ['required', 'min:3', 'max:180'],
'description' => ['max:180'],
'external_auth_id' => ['string'],
'external_auth_id' => ['string', 'max:180'],
'permissions' => ['array'],
'mfa_enforced' => ['string'],
]);
@@ -109,7 +109,7 @@ class RoleController extends Controller
$data = $this->validate($request, [
'display_name' => ['required', 'min:3', 'max:180'],
'description' => ['max:180'],
'external_auth_id' => ['string'],
'external_auth_id' => ['string', 'max:180'],
'permissions' => ['array'],
'mfa_enforced' => ['string'],
]);

View File

@@ -37,27 +37,28 @@ class UserApiController extends ApiController
{
return [
'create' => [
'name' => ['required', 'min:2', 'max:100'],
'name' => ['required', 'string', 'min:2', 'max:100'],
'email' => [
'required', 'min:2', 'email', new Unique('users', 'email'),
'required', 'string', 'email', 'min:2', new Unique('users', 'email'),
],
'external_auth_id' => ['string'],
'language' => ['string', 'max:15', 'alpha_dash'],
'password' => [Password::default()],
'password' => ['string', Password::default()],
'roles' => ['array'],
'roles.*' => ['integer'],
'send_invite' => ['boolean'],
],
'update' => [
'name' => ['min:2', 'max:100'],
'name' => ['string', 'min:2', 'max:100'],
'email' => [
'min:2',
'string',
'email',
'min:2',
(new Unique('users', 'email'))->ignore($userId ?? null),
],
'external_auth_id' => ['string'],
'language' => ['string', 'max:15', 'alpha_dash'],
'password' => [Password::default()],
'password' => ['string', Password::default()],
'roles' => ['array'],
'roles.*' => ['integer'],
],

View File

@@ -3,6 +3,7 @@
namespace BookStack\Users\Controllers;
use BookStack\Access\SocialDriverManager;
use BookStack\Access\UserInviteException;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Http\Controller;
@@ -14,6 +15,7 @@ use BookStack\Util\SimpleListOptions;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
@@ -91,9 +93,15 @@ class UserController extends Controller
$validated = $this->validate($request, array_filter($validationRules));
DB::transaction(function () use ($validated, $sendInvite) {
$this->userRepo->create($validated, $sendInvite);
});
try {
DB::transaction(function () use ($validated, $sendInvite) {
$this->userRepo->create($validated, $sendInvite);
});
} catch (UserInviteException $e) {
Log::error("Failed to send user invite with error: {$e->getMessage()}");
$this->showErrorNotification(trans('errors.users_could_not_send_invite'));
return redirect('/settings/users/create')->withInput();
}
return redirect('/settings/users');
}

View File

@@ -2,6 +2,7 @@
namespace BookStack\Users;
use BookStack\Access\UserInviteException;
use BookStack\Access\UserInviteService;
use BookStack\Activity\ActivityType;
use BookStack\Entities\EntityProvider;
@@ -83,6 +84,7 @@ class UserRepo
* As per "createWithoutActivity" but records a "create" activity.
*
* @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data
* @throws UserInviteException
*/
public function create(array $data, bool $sendInvite = false): User
{

View File

@@ -133,18 +133,30 @@ class CspService
protected function getAllowedIframeSources(): array
{
$sources = config('app.iframe_sources', '');
$hosts = array_filter(explode(' ', $sources));
$sources = explode(' ', config('app.iframe_sources', ''));
$sources[] = $this->getDrawioHost();
// Extract drawing service url to allow embedding if active
return array_filter($sources);
}
/**
* Extract the host name of the configured drawio URL for use in CSP.
* Returns empty string if not in use.
*/
protected function getDrawioHost(): string
{
$drawioConfigValue = config('services.drawio');
if ($drawioConfigValue) {
$drawioSource = is_string($drawioConfigValue) ? $drawioConfigValue : 'https://embed.diagrams.net/';
$drawioSourceParsed = parse_url($drawioSource);
$drawioHost = $drawioSourceParsed['scheme'] . '://' . $drawioSourceParsed['host'];
$hosts[] = $drawioHost;
if (!$drawioConfigValue) {
return '';
}
return $hosts;
$drawioSource = is_string($drawioConfigValue) ? $drawioConfigValue : 'https://embed.diagrams.net/';
$drawioSourceParsed = parse_url($drawioSource);
$drawioHost = $drawioSourceParsed['scheme'] . '://' . $drawioSourceParsed['host'];
if (isset($drawioSourceParsed['port'])) {
$drawioHost .= ':' . $drawioSourceParsed['port'];
}
return $drawioHost;
}
}

View File

@@ -20,7 +20,7 @@ class HtmlDescriptionFilter
*/
protected static array $allowedAttrsByElements = [
'p' => [],
'a' => ['href', 'title'],
'a' => ['href', 'title', 'target'],
'ol' => [],
'ul' => [],
'li' => [],

View File

@@ -8,7 +8,7 @@
"license": "MIT",
"type": "project",
"require": {
"php": "^8.0.2",
"php": "^8.1.0",
"ext-curl": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
@@ -16,15 +16,15 @@
"ext-json": "*",
"ext-mbstring": "*",
"ext-xml": "*",
"bacon/bacon-qr-code": "^2.0",
"barryvdh/laravel-dompdf": "^2.0",
"barryvdh/laravel-snappy": "^1.0",
"bacon/bacon-qr-code": "^3.0",
"doctrine/dbal": "^3.5",
"dompdf/dompdf": "^3.0",
"guzzlehttp/guzzle": "^7.4",
"intervention/image": "^2.7",
"laravel/framework": "^9.0",
"intervention/image": "^3.5",
"knplabs/knp-snappy": "^1.5",
"laravel/framework": "^10.10",
"laravel/socialite": "^5.10",
"laravel/tinker": "^2.6",
"laravel/tinker": "^2.8",
"league/commonmark": "^2.3",
"league/flysystem-aws-s3-v3": "^3.0",
"league/html-to-markdown": "^5.0.0",
@@ -39,17 +39,17 @@
"socialiteproviders/okta": "^4.2",
"socialiteproviders/twitch": "^5.3",
"ssddanbrown/htmldiff": "^1.0.2",
"ssddanbrown/symfony-mailer": "6.0.x-dev"
"ssddanbrown/symfony-mailer": "6.4.x-dev"
},
"require-dev": {
"fakerphp/faker": "^1.21",
"itsgoingd/clockwork": "^5.1",
"mockery/mockery": "^1.5",
"nunomaduro/collision": "^6.4",
"nunomaduro/collision": "^7.0",
"larastan/larastan": "^2.7",
"phpunit/phpunit": "^9.5",
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "^3.7",
"ssddanbrown/asserthtml": "^2.0"
"ssddanbrown/asserthtml": "^3.0"
},
"autoload": {
"psr-4": {
@@ -72,6 +72,10 @@
"lint": "phpcs",
"test": "phpunit",
"t-reset": "@php artisan test --recreate-databases",
"build-licenses": [
"@php ./dev/licensing/gen-js-licenses",
"@php ./dev/licensing/gen-php-licenses"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
@@ -99,7 +103,7 @@
"preferred-install": "dist",
"sort-packages": true,
"platform": {
"php": "8.0.2"
"php": "8.1.0"
}
},
"extra": {

3127
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,17 @@
<?php
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->increments('id');
@@ -26,17 +27,15 @@ return new class extends Migration
'name' => 'Admin',
'email' => 'admin@admin.com',
'password' => bcrypt('password'),
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('users');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('password_resets', function (Blueprint $table) {
$table->string('email')->index();
@@ -21,10 +20,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('password_resets');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('books', function (Blueprint $table) {
$table->increments('id');
@@ -23,10 +22,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('books');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('pages', function (Blueprint $table) {
$table->increments('id');
@@ -27,10 +26,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('pages');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('images', function (Blueprint $table) {
$table->increments('id');
@@ -22,10 +21,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('images');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('chapters', function (Blueprint $table) {
$table->increments('id');
@@ -25,10 +24,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('chapters');
}

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