Compare commits

...

370 Commits

Author SHA1 Message Date
Dan Brown
6c577ac3bf Updated version and assets for release v23.02.3 2023-04-07 18:07:32 +01:00
Dan Brown
31cc2423d2 Merge branch 'v23.02-branch' into release 2023-04-07 18:07:09 +01:00
Dan Brown
3f3f221e0d Updated translator attribution before release v23.02.3 2023-04-07 18:06:44 +01:00
Dan Brown
d0f970fe4f Updated translations with latest Crowdin changes (#4131) 2023-04-07 18:00:03 +01:00
Dan Brown
81134e7071 Fixed tag numbering in last commit 2023-04-07 17:54:17 +01:00
Dan Brown
e722ee4268 Fixed click issue with tag suggestions in safari
Updated selectable elements to be divs instead of buttons since Safari
akwardly does not focus on buttons on click.
Also standardised keyboard handling to our standard nav class.
Also addressed empty tag values showing in results.
For #4139
2023-04-07 17:50:57 +01:00
Dan Brown
fd674d10e3 Fixed error upon user delete with no migration id
Fixes #4162
2023-04-07 15:57:21 +01:00
Dan Brown
c9ed32e518 Updated version and assets for release v23.02.2 2023-03-25 12:27:32 +00:00
Dan Brown
6b4c3a0969 Merge branch 'v23.02-branch' into release 2023-03-25 12:27:05 +00:00
Dan Brown
0a0fdd7f3e Fixed delete role failing with no migrate role provided
For #4128
2023-03-25 12:21:22 +00:00
Dan Brown
3410cf21cb Updated php deps 2023-03-25 12:21:04 +00:00
Dan Brown
6e284d7a6c Fixed issue with user delete ownership not migrating
Caused by input not being part of the submitted form.
Updated test to ensure the input is within a form.
For #4124
2023-03-25 12:20:49 +00:00
Dan Brown
ea7914422c Updated php deps 2023-03-25 12:20:13 +00:00
Dan Brown
509cab3e28 Merged latest crowdin changes 2023-03-25 12:18:45 +00:00
Dan Brown
7b5111571c Removed bookstack wording instances in color setting options 2023-02-28 01:01:25 +00:00
Dan Brown
2dad92d1bd Updated version and assets for release v23.02.1 2023-02-27 19:26:13 +00:00
Dan Brown
c1fb7ab7dc Merge branch 'development' into release 2023-02-27 19:23:33 +00:00
Dan Brown
3464f5e961 Updated translations with latest Crowdin changes (#4066) 2023-02-27 19:19:03 +00:00
Dan Brown
7c27d26161 Fixed language locale setting issue
Attempted to access an array that had been filtered and therefore could
have holes within, including as position 0 which would then be
accessed.
Also added cs language to internal map

Related to #4068
2023-02-27 19:14:45 +00:00
Dan Brown
98315f3899 Updated version and assets for release v23.02 2023-02-26 11:03:49 +00:00
Dan Brown
8c82aaabd6 Merge branch 'development' into release 2023-02-26 11:02:56 +00:00
Dan Brown
c7e33d1981 Fixed caching issue when running tests 2023-02-26 10:50:14 +00:00
Dan Brown
ba21b54195 Updated translations with latest Crowdin changes (#4025) 2023-02-26 10:36:15 +00:00
Dan Brown
f35c42b0b8 Updated php deps and translaters in prep for v23.02 2023-02-25 17:35:21 +00:00
Dan Brown
b88b1bef2c Added updated_at index to pages table
This has a large impact on some areas where latest updated pages are
shown, such as the homepage for example.
2023-02-23 23:06:12 +00:00
Dan Brown
8abb41abbd Added caching to the loading of system roles
Admin system role was being loaded for each permission check performed.
This caches the fetching for the request lifetime.
2023-02-23 23:01:03 +00:00
Dan Brown
a031edec16 Fixed old deprecated encoding convert on HTML doc load 2023-02-23 22:59:26 +00:00
Dan Brown
2724b2867b Merge pull request #4062 from BookStackApp/settings_perf
Changed the way settings are loaded
2023-02-23 22:22:32 +00:00
Dan Brown
8bebea4cca Changed the way settings are loaded
This new method batch-loads them from the database, and removes the
cache-layer with the intention that a couple of batch fetches from the
DB is more efficient than hitting the cache each time.
2023-02-23 22:14:47 +00:00
Dan Brown
6545afacd6 Changed autosave handling for better editor performance
This changes how the editors interact with the parent page-editor
compontent, which handles auto-saving.
Instead of blasting the full editor content upon any change to that
parent compontent, the editors just alert of a change, without the
content. The parent compontent then requests the editor content from the
editor component when it needs that data for an autosave.

For #3981
2023-02-23 12:30:27 +00:00
Dan Brown
31495758a9 Made page-save HTML formatting much more efficient
Replaced the existing xpath-heavy system with a more manual traversal
approach. Fixes following slow areas of old system:
- Old system would repeat ID-setting action for elements (Headers could
  be processed up to three times).
- Old system had a few very open xpath queries for headers.
- Old system would update links on every ID change, which triggers it's
  own xpath query for links, leading to exponential scaling issues.

New system only does one xpath query for links when changes are needed.
Added test to cover.

For #3932
2023-02-22 14:32:40 +00:00
Dan Brown
c80396136f Increased attachment link limit from 192 to 2k
Added test to cover.
Did attempt a 64k limit, but values over 2k significantly increase
chance of other issues since this URL may be used in redirect headers.
Would rather catch issues in-app.

For #4044
2023-02-20 13:05:23 +00:00
Dan Brown
8da3e64039 Updated language files to remove literal "1" values
This is to encourge the ":count" values to be used instead of 1s in the
translated variants so that non-pluralised languages are hardcoded with
"1"s in their content, even when not used in a singular context.

For #4040
2023-02-20 12:05:52 +00:00
Dan Brown
c1167f8821 Merge pull request #4051 from BookStackApp/roles_api
User Roles API Endpoint
2023-02-19 16:11:30 +00:00
Dan Brown
4176b598ce Fixed unselectable checkbox role form options 2023-02-19 16:03:50 +00:00
Dan Brown
950c02e996 Added role API responses & requests
Also applied other slight tweaks and comment updates based upon manual
endpoint testing.
2023-02-19 15:58:29 +00:00
Dan Brown
9502f349a2 Updated test to have reliable check ordering 2023-02-18 19:01:38 +00:00
Dan Brown
3c3c2ae9b5 Set order to role permissions API response 2023-02-18 18:50:01 +00:00
Dan Brown
723f108bd9 Aded roles API controller methods
Altered & updated permissions repo, and existing connected
RoleController to suit.
Also extracts in-app success notifications to auto activity system.
Tweaked tests where required.
2023-02-18 18:36:34 +00:00
Dan Brown
55456a57d6 Added tests for not-yet-built role API endpoints 2023-02-18 13:51:18 +00:00
Dan Brown
fd45d280b4 Updated tinymce from 6.1.0 to 6.3.1 2023-02-17 21:16:42 +00:00
Dan Brown
524adce654 Merge pull request #4049 from BookStackApp/shelf_book_sort_updates
Shelf book sort improvements
2023-02-17 16:20:59 +00:00
Dan Brown
f799c9b260 Applied shelf book sort changes from testing
Added better labelling of sort lists for screen readers.
Fadded out sort-item action buttons until hovering for a cleaner look.
2023-02-17 16:18:24 +00:00
Dan Brown
9c26ccf43d Added shelf book item sort action functionality
Adds JS logic, and dropdown action list, for quick-sorting the book
shelf list in addition to handling the book item action buttons.
2023-02-17 15:53:24 +00:00
Dan Brown
71a09bcf6e Started accessible controls for shelf book sort
Added buttons and fit to design.
Added new icon variations to support.
Extracted book item to own view and setup for future auto sorts.
2023-02-17 15:05:28 +00:00
Dan Brown
af31a6fc1b Made sendmail command configurable
For #4001
Added simple test to cover config option.
2023-02-17 14:25:38 +00:00
Dan Brown
08b39500b3 Fixed gallery images not visible until draft publish
For #4028
2023-02-16 17:57:34 +00:00
Dan Brown
f9fcc9f3c7 Updated php deps 2023-02-16 17:27:09 +00:00
Dan Brown
0812184995 Added torutec as sponsor, updated license and version 2023-02-14 16:16:08 +00:00
Dan Brown
646f8f60c0 Merge pull request #4032 from BookStackApp/favicon
Generate favicon.ico file
2023-02-09 21:37:38 +00:00
Dan Brown
f333db8e4f Added control-upon-access of the default favicon.ico file 2023-02-09 21:16:27 +00:00
Dan Brown
da42fc7457 Added default favicon creation upon access. 2023-02-09 20:57:35 +00:00
Dan Brown
48f1934387 Updated favicon gen to use png-based ICO
From testing, worked on Firefox, Chrome, Gnome Web
2023-02-09 17:47:33 +00:00
Dan Brown
2845e0003e Got favicons better supported, can't get transparency right
Digging deeper, I don't think PHPGD supports 32bit bmp output which
complicates matters.
2023-02-09 15:14:41 +00:00
Dan Brown
1a189640f1 Integrated favicon handler with correct files & actions
Format does not look 100% correct though, won't show in Firefox/gimp.
2023-02-09 13:24:43 +00:00
Dan Brown
420f89af99 Built custom favicon.ico file creator
Followed wikipedia-defined ICO file format info, and used with
Intervention's good bmp support, to create a working proof-of-concept.
2023-02-08 23:06:42 +00:00
Dan Brown
da1a66abd3 Extracted test file handling to its own class
Closes #3995
2023-02-08 14:39:13 +00:00
Dan Brown
5d18e7df79 Removed deprecated syntax in old migration file 2023-02-08 13:20:00 +00:00
Dan Brown
ba25a3e1b7 Merge pull request #4021 from BookStackApp/laravel9
Upgrade framework to Laravel 9
2023-02-07 12:11:04 +00:00
Dan Brown
bc18dc7da6 Removed parallel testing, updated predis
Parallel testing paratest library caused issues due to a single version
not being compatibile across our php range. Removed for now as not
really worth the faff to get compatible.
2023-02-07 11:50:59 +00:00
Dan Brown
5e8ec56196 Fixed issues found from tests 2023-02-06 20:41:33 +00:00
Dan Brown
9ca088a4e2 Fixed static analysis issues 2023-02-06 20:00:44 +00:00
Dan Brown
008e7a4d25 Followed Laravel 9 update steps and file changes 2023-02-06 16:58:29 +00:00
Dan Brown
ce9b536b78 Updated version and assets for release v23.01.1 2023-02-02 12:29:26 +00:00
Dan Brown
d9c50e5bc1 Merge branch 'development' into release 2023-02-02 12:29:07 +00:00
Dan Brown
6e6f113336 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2023-02-02 12:17:06 +00:00
Dan Brown
f7441e2abc Updated translations with latest Crowdin changes (#4008) 2023-02-02 12:16:56 +00:00
Dan Brown
28c168145f Added missing app icon image
Fixes #4006
2023-02-02 11:49:06 +00:00
Dan Brown
c2115cab59 Updated php depenencies 2023-02-02 11:44:25 +00:00
Dan Brown
bf075f7dd8 Updated version and assets for release v23.01 2023-01-31 11:59:51 +00:00
Dan Brown
a4fd673285 Merge branch 'development' into release 2023-01-31 11:59:28 +00:00
Dan Brown
813d140213 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2023-01-31 11:39:21 +00:00
Dan Brown
3dc5942a85 Updated translation attribution before v23.01 release 2023-01-31 11:38:56 +00:00
Dan Brown
03e2a9b200 Updated translations with latest Crowdin changes (#3925) 2023-01-31 11:29:36 +00:00
Dan Brown
8367a94e90 Merge pull request #4002 from BookStackApp/color_upgrades
Better application color scheme control
2023-01-28 17:59:54 +00:00
Dan Brown
631546a68a Adjusted/improved some color setting wording 2023-01-28 17:57:43 +00:00
Dan Brown
7751022c66 Updated migration to carry across more colors, updated export
Updated export to use link color for link.
Export will now copy primary color to link color options for stable
upgrades.
2023-01-28 17:49:48 +00:00
Dan Brown
f42ff59b43 Added migration of color settings to dark mode 2023-01-28 17:31:43 +00:00
Dan Brown
104621841b Update JS to show live changes and set light color values 2023-01-28 17:11:15 +00:00
Dan Brown
c337439370 Rolled out use of seperate link color style 2023-01-28 16:06:11 +00:00
Dan Brown
65ebdb7234 Added usage and defaults for dark colors 2023-01-28 15:20:08 +00:00
Dan Brown
e708ce93ba Updated generic tab styles and js to force accessible usage
Added use of more accessible tags to create tabbed-interfaces then
updated css and JS to require use of those attributes rather than custom
techniques.

Updated relevant parts of app.
Some custom parts using their own tabs though, something to improve in
future.
2023-01-28 12:50:51 +00:00
Dan Brown
1f69965c1e Updated settings view to have dark-mode color options
Also added link color option, not yet used.
Cleaned up tabbed interface control design as part of this.
2023-01-28 11:50:46 +00:00
Dan Brown
d7723b33f3 Merge pull request #3999 from BookStackApp/sort_ui_improvements
Improve Book Sorting User Experience
2023-01-27 18:02:14 +00:00
Dan Brown
87e371ffde Added prevention of nested chapters on sort 2023-01-27 17:39:51 +00:00
Dan Brown
b649738718 Made book-sort changes based on screen reader testing
- Removed having sort items in tabbing order since they have no action.
- Updated "show other books" list to add upon single selection since it
  was not clear how these were added (double press) without then seeing
the add button, and even then the add button would be after the scroll
list.
2023-01-27 17:06:39 +00:00
Dan Brown
022cbb9c00 Finished off design and fixing of sort buttons 2023-01-27 16:25:06 +00:00
Dan Brown
40e112fc5b Extracted text & added dropdown for book sort move actions
Primarily styling and testing left to do.
2023-01-27 13:26:58 +00:00
Dan Brown
7cacbaadf0 Added functionality/logic for button-based sorting 2023-01-27 13:08:35 +00:00
Dan Brown
a3e7e754b9 Improves sortable ux
- Fixes multi-select functionality.
- Updated other books to be sticky.
- Added some general intro/desc text.
- Updated sort boxes to be collapsible.
- Cleaned up other books styling.
2023-01-27 11:16:17 +00:00
Dan Brown
03ad288aaa Updated user avatar reset to clear relation id in database
Added test to cover.
For #3977
2023-01-26 17:15:09 +00:00
Dan Brown
811be3a36a Added option to change the OIDC claim regarded as the ID
Defined via a OIDC_EXTERNAL_ID_CLAIM env option.
For #3914
2023-01-26 16:43:15 +00:00
Dan Brown
3202f96181 Tweak tag list to add new row on input instead of change
Prevented interferance with the user's action if they interacted with
something below the tags, since a new row would be added on blur and
hence shift down positions.

For #3931
2023-01-26 16:10:47 +00:00
Dan Brown
f6a6b11ec5 Added and addressed multi-role/own-role-perm/inheretance scenario
Found during manual testing.
Have checked against relation queries manually too.
2023-01-26 12:53:25 +00:00
Dan Brown
48df8725d8 Added better drawing load failure handling
Failure of loading drawings will now close the drawing view and show an
error message, hinting at file or permission issues, instead of leaving
the user facing a continuosly loading interface.

Adds test to cover.

This also updates errors from our HTTP service to be wrapped in a custom
error type for better identification and so the error is an actual
javascript error. Should be object compatible.

Related to #3955.
2023-01-26 12:18:33 +00:00
Dan Brown
25bdd71477 Add scheme and sql-variant code language options
For #3954 and #3942
2023-01-26 11:26:20 +00:00
Dan Brown
deda331745 Fixed global search preview click on safari
Safari needs an element to be focusable to be able to use :focus-within.
For #3926
2023-01-25 21:46:26 +00:00
Dan Brown
f6d3944b20 Merge pull request #3994 from BookStackApp/app_icon_setting
Added ability to control app icon (favicon) via settings
2023-01-25 16:50:48 +00:00
Dan Brown
a50b0ea1e5 Covered app icon setting with testing 2023-01-25 16:41:41 +00:00
Dan Brown
3c658e39ab Extracted app icon text, fixed issues
Tweaked sizes and meta tags based unpon ipad testing.
Fixed reduced sizes not being cleaned up.
2023-01-25 16:11:34 +00:00
Dan Brown
d8354255e7 Added practicali to sponsor list 2023-01-25 12:06:11 +00:00
Dan Brown
55b6a7842e Added ability to control app icon (favicon) via settings 2023-01-25 11:03:19 +00:00
Dan Brown
0f113ec41f Merge pull request #3986 from BookStackApp/permission_testing
Permission Testing & Alignment
2023-01-24 21:37:28 +00:00
Dan Brown
1fa5a31960 Fixed role entity permissions ignoring inheritance
Added additional scnenario tests to cover
2023-01-24 21:26:41 +00:00
Dan Brown
8be36455ab Addressed fallback override cases found during testing
Had misalignment between query and usercan, The nuance between fallback
and entity-role permissions was not taken into account by the query
system. Now added with new test cases to cover.
2023-01-24 20:42:20 +00:00
Dan Brown
d1bd6d0e39 Fixed incorrect field in down migration 2023-01-24 19:21:23 +00:00
Dan Brown
1660e72cc5 Migrated remaining relation permission usages
Now all tests are passing.
Some level of manual checks to do.
2023-01-24 19:04:32 +00:00
Dan Brown
2d1f1abce4 Implemented alternate approach to current joint_permissions
Is a tweak upon the existing approach, mainly to store and query role
permission access in a way that allows muli-level states that may
override eachother. These states are represented in the new PermissionStatus
class.

This also simplifies how own permissions are stored and queried, to be
part of a single column.
2023-01-24 14:55:34 +00:00
Dan Brown
7d74575eb8 Found a sql having-style approach to permissions
As a way to check aggregate queries for required changes to need to
analyse across combined permission values.
2023-01-24 13:44:38 +00:00
Dan Brown
91e613fe60 Shared entity permission logic across both query methods
The runtime userCan() and the JointPermissionBuilder now share much of
the same logic for handling entity permission resolution.
2023-01-23 15:09:03 +00:00
Dan Brown
f3f2a0c1d5 Updated userCan logic to meet expectations in tests
Updated with similar logic to that used in the user_permissions branch,
but all extracted to a seperate class for doing all fetch and collapse
work.
2023-01-23 12:40:11 +00:00
Dan Brown
1c2ae7bff6 Added gmp extension to test workflow
If was not already enabled by default, should enable faster testing
handling as it helps the phpseclib usage for OIDC tokens in test rocket
through.
2023-01-21 21:34:39 +00:00
Dan Brown
78ebcb6f38 Addressed a range of deprecation warnings
Closes #3969
2023-01-21 20:50:04 +00:00
Dan Brown
28dda39260 Updated PHP and JS depenencies 2023-01-21 19:09:19 +00:00
Dan Brown
e2a72d16aa Made adjustments to fit copied work into dev branch
Ported non-compatible elements, Now all tests passing apart from some
specific permission scenario tests which are probably correctly failing.
Updates some tests to better avoid messing environment state.
2023-01-21 13:03:47 +00:00
Dan Brown
c724bfe4d3 Copied over work from user_permissions branch
Only that relevant to the additional testing work.
2023-01-21 11:08:34 +00:00
Dan Brown
6070d804f8 Fixed incorrect pluralisation for de_informal
Updated language system to only use initial part of locale for
translation pluralisation to better match the hard-coded logic of the
built-in MessageSelector. Extends and overrides Laravel's default for
this system.

Added test to cover.
Related to #3976.
2023-01-16 16:56:41 +00:00
Dan Brown
e794c977bc Updated version and assets for release v22.11.1 2022-12-16 23:49:14 +00:00
Dan Brown
0b088ef1d3 Merge branch 'development' into release 2022-12-16 23:48:35 +00:00
Dan Brown
5393465ea7 Updated translator attribution before release v22.11.1 2022-12-16 23:48:04 +00:00
Dan Brown
f5df811b15 Removed old unused style definition 2022-12-16 23:21:24 +00:00
Dan Brown
a521f41838 Fixed lack of scroll in editor toolbox contents
For #2887
2022-12-16 23:16:51 +00:00
Dan Brown
0123d83fb2 Fixed not being able to remove all user roles
User roles would only be actioned if they existed in the form request,
hence removal of all roles would have no data to action upon.
This adds a placeholder 0-id role to ensure there is always role data to
send, even when no roles are selected. This field value is latter
filtered out.

Added test to cover.

Likely related to #3922.
2022-12-16 17:44:13 +00:00
Dan Brown
559e392f1b Merge branch 'development' of https://github.com/jhit/BookStack into jhit-development 2022-12-16 17:12:57 +00:00
Dan Brown
8468b632a1 Updated crowdin config with PR title and labels
Aligns to the title and labelling I already do manually.
2022-12-16 17:11:01 +00:00
Dan Brown
7053a8669f New Crowdin updates (#3881) 2022-12-16 17:06:52 +00:00
Dan Brown
2c0a7346b1 Prevent search focus change on left/right arrow press
For #3920
2022-12-16 17:03:48 +00:00
Dan Brown
bf6a6af683 Updated version and assets for release v22.11 2022-11-30 12:30:21 +00:00
Dan Brown
914790fd99 Merge branch 'development' into release 2022-11-30 12:29:52 +00:00
Dan Brown
69d702c783 Updated locale list to align with lang folders 2022-11-30 12:13:50 +00:00
Dan Brown
dd92cf9e96 Updated translator attribution before v22.11 release 2022-11-30 12:02:10 +00:00
Dan Brown
0cd0b44cdb New Crowdin updates (#3828) 2022-11-30 12:01:19 +00:00
Jürgen Hörmann
d505642336 Add popular PHP templating languages to code editor
Smarty and Twig are two very popular PHP templating engines and might be
useful to some Bookstack users too.
2022-11-29 14:53:41 +01:00
Dan Brown
31c28be57a Converted md settings to localstorage, added preview resize 2022-11-28 14:08:20 +00:00
Dan Brown
38db3a28ea Merge pull request #3878 from BookStackApp/dark_style_cleanup
Cleaned up dark mode styles inc. setting browser color scheme
2022-11-28 12:42:16 +00:00
Dan Brown
09fa2d2c9c Cleaned up dark mode styles inc. setting browser color scheme
Forces browser colorscheme based on BookStack color scheme, via
'color-scheme' css property.
Sets proper dark mode colors for some previously missed areas like
templates and attachment control buttons.
Also fixed search bar icon position for some search inputs.
2022-11-28 12:38:30 +00:00
Dan Brown
b786ed07be Merge pull request #3875 from BookStackApp/md_editor_updates
Markdown Editor Updates
2022-11-28 12:21:33 +00:00
Dan Brown
0527c4a1ea Added test to preference boolean endpoint 2022-11-28 12:17:22 +00:00
Dan Brown
ec3713bc74 Connected md editor settings to logic for functionality 2022-11-28 12:12:36 +00:00
Dan Brown
9fd5190c70 Added md editor ui dropdown options & their back-end storage
Still need to perform actual in-editor functionality for those controls.
2022-11-27 20:30:14 +00:00
Dan Brown
3995b01399 Tightened existing markdown editor styles 2022-11-27 19:52:10 +00:00
Dan Brown
3fdb88c7aa Added callout cycling in markdown editor via shortcut 2022-11-26 23:18:51 +00:00
Dan Brown
8e4bb32b77 Fixed md editor refactoring issues after manual test
Testing was a full manual feature test of each piece of supported logic
defined in the code.
2022-11-26 21:33:39 +00:00
Dan Brown
63d6272282 Refactored markdown editor logic
Split out the markdown editor logic into seperate components to provide
a more orgranised heirachy with feature-specific files.
2022-11-26 16:43:28 +00:00
Dan Brown
40a1377c0b Fixed tests to align with recent changes, Updated php deps 2022-11-23 12:08:55 +00:00
Dan Brown
e20c944350 Fixed OIDC handling when no JWKS 'use' prop exists
Now assume, based on OIDC discovery spec, that keys without 'use' are
'sig' keys. Should not affect existing use-cases since existance of such
keys would have throw exceptions in prev. versions of bookstack.

For #3869
2022-11-23 11:50:59 +00:00
Dan Brown
85b7b10c01 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2022-11-23 00:13:02 +00:00
Dan Brown
35f73bb474 Updated global search component to new format 2022-11-23 00:12:41 +00:00
Dan Brown
ffc9c28ad5 Merge branch 'search_preview' into development 2022-11-23 00:10:21 +00:00
Dan Brown
fcff206853 Adjusted global search preview for dark mode 2022-11-23 00:05:24 +00:00
Dan Brown
0e528986ab Extracted keyboard nav. from dropdowns to share w/ search 2022-11-21 17:35:19 +00:00
Dan Brown
e7e83a4109 Added new endpoint for search suggestions 2022-11-21 10:35:53 +00:00
Dan Brown
891543ff0a Merge pull request #3852 from BookStackApp/php82
PHP8.2 Support
2022-11-20 22:21:52 +00:00
Dan Brown
c617190905 Added global search input debounce and loading indicator 2022-11-20 22:20:31 +00:00
Dan Brown
2c1f20969a Replaced JS logic with CSS focus-within logic 2022-11-20 21:53:53 +00:00
Dan Brown
851ab47f8a Fixed input styles in search preview mode, added animation
Also added JS handlers for hiding the suggestions
2022-11-20 21:50:59 +00:00
Dan Brown
bbf13e9242 Merge pull request #3853 from BookStackApp/component_refactor
Started refactor and alignment of JS component system
2022-11-16 16:05:57 +00:00
Dan Brown
05a24ea355 Updated js dev docs with latest component changes 2022-11-16 16:02:31 +00:00
Dan Brown
be736b3939 Replaced el.components mapping with component service weakmap
Old system was hard to track in terms of usage and it's application of
'components' properties directly to elements was shoddy.
This routes usage via the components service, with element-specific
component usage tracked via a local weakmap.
Updated existing found usages to use the new system.
2022-11-16 15:46:41 +00:00
Dan Brown
25c23a2e5f Removed use of image-manager/entity-selector window globals 2022-11-16 15:21:22 +00:00
Dan Brown
3b8ee3954e Finished updating remainder of JS components to new system 2022-11-16 13:06:08 +00:00
Dan Brown
db79167469 Updated a whole load more js components 2022-11-15 16:04:46 +00:00
Dan Brown
b37e84dc10 Updated another set of components 2022-11-15 12:44:57 +00:00
Dan Brown
4310d34135 Updated a batch of JS components 2022-11-15 11:24:31 +00:00
Dan Brown
09c6a3c240 Started refactor and alignment of component system
- Updates old components to newer format, removes legacy component
support.
- Makes component registration easier and less duplicated.
- Adds base component class to extend for better editor support.
- Aligns global window exposure usage and aligns with other service
  names.
2022-11-14 23:19:02 +00:00
Dan Brown
796f4090b5 Added php8.2 to GH action checks 2022-11-14 18:26:01 +00:00
Dan Brown
19a792bc12 Started on a live-preview on global search input 2022-11-14 10:24:14 +00:00
Dan Brown
a1b1f8138a Updated email confirmation flow so confirmation is done via POST
To avoid non-user GET requests (Such as those from email scanners)
auto-triggering the confirm submission. Made auto-submit the form via
JavaScript in this extra added step with user-link backup to keep
existing user flow experience.

Closes #3797
2022-11-12 15:11:59 +00:00
Dan Brown
0e627a6e05 Merge pull request #3848 from BookStackApp/auth_message_partials
Added login/register message partials for easier use via theme system
2022-11-12 09:03:59 +00:00
Dan Brown
d2cd33e226 Added login/register message partials for easier use via theme system
Related to #608
2022-11-12 09:02:33 +00:00
Dan Brown
2fa5c2581c Added swift support to code blocks and editor
Closes #3847
2022-11-12 08:44:25 +00:00
Dan Brown
d2260b234c Fixed app logo visibility with secure_restricted images
Includes test to cover.
For #3827
2022-11-10 14:15:59 +00:00
Dan Brown
832356d56e Added test to cover books perms. gen with deleted chapter
Closes #3796
2022-11-10 13:48:17 +00:00
Dan Brown
5fd1c07c9d Added dart support to code blocks/editing
For #3808
2022-11-10 13:38:56 +00:00
Dan Brown
4c75358abd Extracted hardcoded english text to language files
Closes #3822
2022-11-10 13:30:48 +00:00
Dan Brown
d520d6cab8 Merge pull request #3830 from BookStackApp/shortcuts
User interface shortcuts system
2022-11-10 10:32:56 +00:00
Dan Brown
737904fa63 Extracted shortcut text to language files 2022-11-10 10:25:28 +00:00
Dan Brown
a3fcc98d6e Aligned user preference endpoints in style and behaviour
Changes their endpoints and remove the user id from the URLs.
Simplifies list changes to share a single endpoint, which aligns it to
the behaviour of the existing sort preference endpoint.
Also added test to ensure user preferences are deleted on user delete.
2022-11-09 19:30:08 +00:00
Dan Brown
24a7e8500d Added tests to cover shortcut endpoints 2022-11-09 18:42:54 +00:00
Dan Brown
9067902267 Added shortcut input controls to make custom shortcuts work 2022-11-09 14:40:44 +00:00
Dan Brown
66c8809799 Started interface user shortcut form interface
Built controller actions and initual UI.
Still needs JS logic for shortcut input handling.
2022-11-08 21:17:45 +00:00
Dan Brown
1fc994177f Improved shortcut overlay with related action highlighting 2022-11-05 13:57:22 +00:00
Dan Brown
78b6450031 Distributed shortcut actions to common ui elements 2022-11-05 13:39:17 +00:00
Dan Brown
b4cb375a02 Started implementation of UI shortcuts system 2022-11-04 15:20:19 +00:00
Dan Brown
33e5c85503 Merge pull request #3821 from BookStackApp/list_reworks
Revision of item list views
2022-11-03 14:52:40 +00:00
Dan Brown
9e8240a736 Addressed additional unsupported array spread operation 2022-11-03 14:40:01 +00:00
Dan Brown
37afd35b6f Fixed use of array unpacking syntax
Since it was using keyed arrays, unpacking is only supported in php8.1+
2022-11-03 14:33:23 +00:00
Dan Brown
6364c541ea Fixed phpstan static usage warning, updated ci flows
CI flow updates to follow deprecation warnings
2022-11-03 14:14:22 +00:00
Dan Brown
8ec6b07690 Updated role permission table to responsive format 2022-11-03 13:28:07 +00:00
Dan Brown
7101ec09ed Updated search term lists to flex layouts 2022-11-03 12:49:05 +00:00
Dan Brown
2c5efddf6c Merge branch 'v22-10' into development 2022-11-02 15:22:53 +00:00
Dan Brown
edb0c6a9e8 Updated version and assets for release v22.10.2 2022-11-02 15:22:13 +00:00
Dan Brown
84049de696 Merge branch 'v22-10' into release 2022-11-02 15:19:33 +00:00
Dan Brown
a37bdffcd9 Updated translator attribution before release v22.10.2 2022-11-02 15:19:13 +00:00
Dan Brown
e95ab36f76 Merged and squashed l10n_development into v22-10 2022-11-02 15:17:54 +00:00
Dan Brown
f809bd3a62 Updated tests to align with recent list changes 2022-11-01 14:53:36 +00:00
Dan Brown
d4e71e431b Revised revision list to responsive layout 2022-10-31 21:26:31 +00:00
Dan Brown
de807f8538 Updated recycle bin list to new responsive layout 2022-10-31 16:45:32 +00:00
Dan Brown
80d2889217 Updated tags list to new responsive format 2022-10-31 11:40:28 +00:00
Dan Brown
9e8516c2df Tweaked list spacings a little to align paddings 2022-10-30 21:06:42 +00:00
Dan Brown
09f2bc28d2 Removed addition detail spacing in audit list 2022-10-30 20:29:21 +00:00
Dan Brown
be320c5501 Adjusted audit log row spacing a tad 2022-10-30 20:27:41 +00:00
Dan Brown
2bbf7b2194 Revised audit log list to new responsive format 2022-10-30 20:24:08 +00:00
Dan Brown
ab184c01d8 Updated API tokens list to new responsive format 2022-10-30 15:37:52 +00:00
Dan Brown
2c114e1a4a Split out user controller preference methods to new controller 2022-10-30 15:25:02 +00:00
Dan Brown
ec4cbbd004 Refactored common list handling operations to new class 2022-10-30 15:16:06 +00:00
Dan Brown
f75091a1c5 Revised webhooks list to new format
Also aligned query naming to start with model in use.
Also added created/updated sort options to roles.
2022-10-30 12:02:06 +00:00
Dan Brown
98b59a1024 Revised role index list to align with user list 2022-10-29 20:52:17 +01:00
Dan Brown
0ef06fd298 Extracted user list item to its own template 2022-10-29 15:25:28 +01:00
Dan Brown
986346a0e9 Redesigned users list to be responsive and aligned 2022-10-29 15:23:21 +01:00
Dan Brown
2a65331573 Worked towards phpstan level 2, 13 errors remain 2022-10-24 12:12:48 +01:00
Dan Brown
45d0860448 Updated npm package versions 2022-10-24 11:40:05 +01:00
Dan Brown
da0531e63b Updated version and assets for release v22.10.1 2022-10-21 21:52:32 +01:00
Dan Brown
421dc75f4e Merge branch 'development' into release 2022-10-21 21:52:16 +01:00
Dan Brown
ea6eacb400 Fixed chapter fetching during joint permission building
Somehow I accidentally deleted previous line 143 in this commit:
3839bf6bf1
which would then break permission generation for content related to, or
containing, chapters in the recycle bin.
Found via user report (subz) & debugging in discord.
2022-10-21 21:49:29 +01:00
Dan Brown
8ae91df038 Updated version and assets for release v22.10 2022-10-21 11:16:45 +01:00
Dan Brown
64b41dd626 Merge branch 'development' into release 2022-10-21 11:16:25 +01:00
Dan Brown
103649887f Updated translator attribution before release v22.10 2022-10-21 11:15:35 +01:00
Dan Brown
7b2fd515da Updated test to align with latest translation 2022-10-21 10:41:55 +01:00
Dan Brown
3f61bfc43c Fixed toggle controls on added content permission role rows 2022-10-21 10:13:11 +01:00
Dan Brown
905d339572 Added greek language option 2022-10-20 12:25:02 +01:00
Dan Brown
5d37a814fd New Crowdin updates (#3737) 2022-10-20 12:18:58 +01:00
Dan Brown
f9c0edbd0c Set fixed cell widths for users list table
To prevent certain cells squashing others.
Related to #3787.
2022-10-19 11:15:17 +01:00
Dan Brown
d084f225a0 Updated page pointer to use a fixed positioning system
Avoids interferance with elements that have their own overflow behaviour
such as table cells.
Related to #3774
2022-10-18 22:40:13 +01:00
Dan Brown
ff3fb2ebb9 Extracted page pointer to its own compontent 2022-10-18 22:02:34 +01:00
Dan Brown
725ff5a328 Updated php deps 2022-10-16 09:54:07 +01:00
Dan Brown
f0ac454be1 Prevented saml2 autodiscovery on metadata load
Fixes issue where metadata cannot be viewed if autload is active and
entityid url is not active.
For #2480
2022-10-16 09:50:08 +01:00
Dan Brown
0269f5122e Added wysiwyg code block edit tooltip
For easier editing access on mobile devices where previous doubleclick
does not work so well.
For #2815
2022-10-15 15:47:34 +01:00
Dan Brown
6adc642d2f Merge branch 'development' into bugfix/fix-being-unable-to-clear-filters 2022-10-15 15:12:55 +01:00
Dan Brown
22a91c955d Merge pull request #3760 from BookStackApp/item_permission_revamp
Refactor of item-level permission to be more intuitive
2022-10-14 17:34:51 +01:00
Dan Brown
6951aa3d39 Fixed permission row permission check 2022-10-14 16:03:06 +01:00
Dan Brown
bd412ddbf9 Updated test for perms. changes and fixed static issues 2022-10-12 12:12:36 +01:00
Dan Brown
7792da99ce Updated entity perms. changes for dark mode support 2022-10-12 11:27:24 +01:00
Dan Brown
98c6422fa6 Extracted entity perms. text to translation files 2022-10-11 15:52:56 +01:00
Dan Brown
25708542ff Refined design and text for entity permission changes 2022-10-11 15:41:21 +01:00
Dan Brown
0fae807713 Fixed and updated "Everyone Else" permissions handling
- Fixed inheriting control for new system.
- Tested copying shelf permissions to books.
- Added additional handling for inheriting scenario identification.
2022-10-10 17:22:38 +01:00
Dan Brown
0f68be608d Removed most usages of restricted entitiy property 2022-10-10 16:58:26 +01:00
Dan Brown
63056dbef4 Updated restricted usage on search and entity meta details
Also removed now unused view.
2022-10-10 16:22:51 +01:00
Dan Brown
803934d020 Added interface for adding/removing roles in entity perms. 2022-10-10 12:24:23 +01:00
Dan Brown
ffd6a1002e Centralised handling of permission form data to own class
Also updates show roles on permission view to just those with
permissions applied.
Fixes rounded borders for lone permission rows.
Moves "Everyone Else" handling from role to new class.
2022-10-09 17:14:11 +01:00
Dan Brown
bf591765c1 Reorgranised permission routes into their own controller
Also introduced helpers for getting entities by slugs since we do it in
so many places.
2022-10-09 16:36:03 +01:00
Dan Brown
06a7f1b54a Added migration to drop entity restricted field 2022-10-08 15:30:03 +01:00
Dan Brown
3839bf6bf1 Updated joint perms. gen. to use new entity permission format 2022-10-08 14:28:44 +01:00
Dan Brown
aee0e16194 Started code update for new entity permission format 2022-10-08 13:52:59 +01:00
Dan Brown
1d3dbd6f6e Migrated entity_permissions table to new flat format
Simplifies structure and limits content count, while allowing direct
mapping of new UI intent, where we may have entries with no permissions.
Not yet updated app logic to suit.

Tested via migrating and rolling-back, then comparing export data,
across a set of custom permission entries.
2022-10-07 15:07:09 +01:00
Dan Brown
1df9ec9647 Added proper entity permission removal on role deletion
Added test to cover.
2022-10-07 13:12:33 +01:00
Allan
d4143c3101 Only output hidden user filters when not set to 'me' 2022-10-06 19:25:47 +02:00
Dan Brown
a03245e427 Added user-interface for "Everyone Else" entity permission item
Nothing on back-end logic done to hook this new option up.
Addition of permissions for role_id=0 works out of the box, but active
"everyone else" permissions, with no priviliges, is currently not
working. Needs change of permission gen logic also.
2022-10-02 18:09:48 +01:00
Dan Brown
a090720241 Developed dev JS docs a bit further 2022-10-02 14:27:12 +01:00
Dan Brown
b8b0afa0df Cleaned up old permission JS code
Removed now unused JS entity-permissions compontent.
Updated existing permissions-table compontent to newer format.
Removed now unused translation string.
2022-10-02 13:57:32 +01:00
Dan Brown
f19bad8903 Started item permission design revamp 2022-10-02 13:17:28 +01:00
Dan Brown
953402f2eb Started playing with table icons
To make a little more accessible, Related to #3397
2022-09-30 18:37:37 +01:00
Dan Brown
8c945034b9 Merge pull request #3757 from BookStackApp/tests_entity_cleanup
Testing cleanup
2022-09-29 22:18:34 +01:00
Dan Brown
900e853b15 Quick run through of applying new test entity helper class 2022-09-29 22:11:16 +01:00
Dan Brown
b56f7355aa Migrated much test entity usage via find/replace 2022-09-29 17:31:38 +01:00
Dan Brown
068a8a068c Extracted entity testcase methods to own class
Also added some new fetch helper methods for future use.
2022-09-29 16:49:25 +01:00
Dan Brown
0e94fd44a8 Added contents to book-show endpoint
Created a generic list formatting helper class for this, to align with
logic used on the search results endpoint and for easier future re-use
in a standardised way.
Also updated some class property types.
Added test to cover new books-contents results.
Related to #3734
2022-09-29 15:08:18 +01:00
Dan Brown
ccbc68b560 Updated shelf book management to allow scroll on mobile
Updates book drag handling to be limited to the handle so scrolling can
be done on the items themselves.
Increased handling area and improved styling to support
2022-09-28 20:48:29 +01:00
Dan Brown
f79b7bc799 Added api format advisory regarding PUT/DELETE form data 2022-09-28 20:15:48 +01:00
Dan Brown
60171b3522 Updated book copy to copy shelf relations
Where permission to edit the shelf is allowed.
For #3699
2022-09-28 14:14:51 +01:00
Dan Brown
8f3430d386 Improved tag suggestion handling
- Aligned prefix-type filtering with back-end.
- Increased suggestion search cut-off from 3 to 4.
- Increased amount of suggestions shown.
- Ordered suggestions to be name asc, as you'd expect on search.
- Updated front-end filtering to use full search query, instead of
  truncated version, for further front-end filtering capability.

Related to #3720
2022-09-28 13:50:40 +01:00
Dan Brown
1ac1cf0c78 Applied permissions to revision action visibility
Related to #3723
2022-09-28 11:10:06 +01:00
Dan Brown
6dd89ba956 Split out some development-specific readme parts to own pages 2022-09-27 20:11:58 +01:00
Dan Brown
bf56254077 Merge branch 'auth_review' into development 2022-09-27 19:34:48 +01:00
Dan Brown
d933fe5dce Updated WYSIWYG config to allow styles on list elements 2022-09-27 19:05:03 +01:00
Dan Brown
391fb2cc62 Added MATLAB/Octave code highlighting support 2022-09-27 18:52:21 +01:00
Dan Brown
af11e7dd54 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2022-09-27 18:45:08 +01:00
Dan Brown
af434d0216 Fixed custom code theme not showing in WYSIWYG
Fixes #3753
Was caused by not including added styles to the code block shadow root.
2022-09-27 18:44:06 +01:00
Dan Brown
931641ed2c Tweaked license and readme text
Updated license copyright line to better help it be detected as MIT by
automatic license systems (Such as GitHub license detection) while
removing contributors link which would not actually list all
contributors.
Also added year range back in to be more specific about active lifetime.
2022-09-27 12:23:16 +01:00
Dan Brown
b716fd2b8b Updated composer deps, incremented dev version 2022-09-27 02:56:49 +01:00
Dan Brown
a6a78d2ab5 Refactored app service providers
Removed old pagination provider as url handling now achieved in a better
way.
Removed unused broadcast service provider.
Moved view-based tweaks into specific provider.
Reorganised provider config list.
2022-09-27 02:48:05 +01:00
Dan Brown
67d7534d4f Merge pull request #3751 from BookStackApp/parallel_testing
Parallel Testing Support
2022-09-27 01:31:37 +01:00
Dan Brown
f21669c0c9 Cleaned testing service provider usage
Moved testing content out of AppServiceProvider, to a testing-specific
service provider. Updated docs and added composer commands to support
parallel testing.
Also reverted unintentional change to wysiwyg/config.js.
2022-09-27 01:27:51 +01:00
Dan Brown
e18033ec1a Added initial support for parallel testing 2022-09-26 21:25:32 +01:00
Dan Brown
5c5ea64228 Added login throttling test, updated reset-pw test method names 2022-09-22 17:29:38 +01:00
Dan Brown
90b4257889 Split out registration and pw-reset tests methods 2022-09-22 17:15:15 +01:00
Dan Brown
f4388d5e4a Removed usage of laravel/ui dependency
Brings app auth controller handling aligned within the app, rather than
having many overrides of the framwork packages causing confusion and
messiness over time.
2022-09-22 16:54:27 +01:00
Dan Brown
7165481075 Updated auth controllers with property types 2022-09-22 15:12:05 +01:00
Dan Brown
ebd6e4d3a2 Updated version and assets for release v22.09.1 2022-09-20 13:19:34 +01:00
Dan Brown
80374aea5c Merge branch 'development' into release 2022-09-20 13:19:03 +01:00
Dan Brown
aec772c5eb Updated translator attribution before release v22.09.1 2022-09-20 13:18:41 +01:00
Dan Brown
2e4d29e062 New Crowdin updates (#3710) 2022-09-20 13:16:15 +01:00
Dan Brown
dce6a82954 Added reason, if existing, into SAML acs error
Closes #3731
2022-09-20 12:52:44 +01:00
Dan Brown
050d69ea27 Added extra setlocale format to help windows support
Related to #3650
2022-09-20 12:00:14 +01:00
Dan Brown
0cc68b7665 Fixed language request link in readme 2022-09-19 17:24:21 +01:00
Dan Brown
75d6b56072 Merge pull request #3728 from BookStackApp/php_formatting
Addition of PHPCS for formatting
2022-09-18 15:04:07 +01:00
Dan Brown
ac27b5aebb Updated readme for phpcs usage, aligned gh action workflows 2022-09-18 14:50:25 +01:00
Dan Brown
ecbc7344fc Added php lint gh action, updated composer scripts 2022-09-18 01:56:45 +01:00
Dan Brown
8a749c6acf Added and ran PHPCS 2022-09-18 01:25:20 +01:00
Dan Brown
2ac9efae7d Updated version and assets for release v22.09 2022-09-08 12:41:09 +01:00
Dan Brown
a11d565ba4 Merge branch 'development' into release 2022-09-08 12:40:57 +01:00
Dan Brown
d0dc5e5c5d Added a little protection to migration query
Just to be sure the query is filtered as expected to only affect
shelf-based images.
2022-09-08 12:26:14 +01:00
Dan Brown
e4642257a6 New Crowdin updates (#3701) 2022-09-08 11:59:57 +01:00
Dan Brown
f7418d0600 Updated translator attribution 2022-09-08 11:58:55 +01:00
Dan Brown
98aed794cc Made a range of rtl fixes
Mostly around dropdowns and other items that had right/left specific
styling.
For #3702
2022-09-06 21:31:18 +01:00
Dan Brown
623ccd4cfa Removed old thai files, added romanian as lang option
Also applied styleci changes
2022-09-06 17:41:32 +01:00
Dan Brown
d8672944a5 Added image view access notice to role form
Added to clarify the role permission in scenarios where users may have
not read the docs site to understand image access control.

Related to #3688
2022-09-06 17:20:35 +01:00
Dan Brown
6955b2fd5a Widened svg content attribute xss filtering
Takes care of additional cases that can occur.
Closes #3705
2022-09-06 17:01:56 +01:00
Dan Brown
24f82749ff Updated OIDC group attr option name
To match the existing option name for display names.
Closes #3704
2022-09-06 16:33:17 +01:00
Dan Brown
b9941e8e61 Merge pull request #3698 from BookStackApp/include_theme_event
Added "page_include_parse" theme event
2022-09-05 16:51:01 +01:00
Dan Brown
7101ce3050 Added "page_include_parse" theme event
For custom control of include tag parsing.
2022-09-05 16:40:42 +01:00
Dan Brown
fbef0d06f2 Added permission visiblity control to image-delete button
Includes test to cover.
For #3697
2022-09-05 15:52:12 +01:00
Dan Brown
b698bb0e07 Wrapped wysiwyg drawing change in editor transaction
To make the content changes made a undoable transaction that is picked
up as a change.
From my testing, should address #3682
2022-09-05 15:06:47 +01:00
Dan Brown
2d7552aa09 Addressed setlocale issue caught by phpstan
setlocale could be called with no second param if the language given to
the modified function was empty.
2022-09-05 13:33:05 +01:00
Dan Brown
ee1e936660 Applied styleci changes, updated composer deps 2022-09-05 13:18:37 +01:00
Dan Brown
50214d5fe6 New Crowdin updates (#3643) 2022-09-05 13:17:10 +01:00
Dan Brown
2fe261e207 Updated page revisions link visibility
To match the actual visibilities of the revisions listing page and
options.
Related to #2946
2022-09-03 12:32:21 +01:00
Dan Brown
9158a66bff Updated & improved language locale handling
Extracted much of the language and locale work to a seperate, focused class.
Updated php set_locale usage to prioritise UTF8 usage.
Added locale options for windows.
Clarified what's a locale and a bookstack language string.

For #3590 and maybe #3650
2022-09-02 19:19:01 +01:00
Dan Brown
7f8b3eff5a Fixed failing tests due to shelf text changes, applied styleci changes 2022-09-02 14:47:44 +01:00
Dan Brown
5736919836 Merge pull request #3693 from BookStackApp/local_secure_restricted
Addition of a `local_secure_restricted` image storage option
2022-09-02 14:41:25 +01:00
Dan Brown
c76b5e2ec4 Fixed local_secure_restricted preventing attachment uploads
Due to option name change and therefore lack of handling.
Added test case to cover.
2022-09-02 14:40:17 +01:00
Dan Brown
092b6d6378 Added test and handling for local_secure_restricted in exports 2022-09-02 14:21:43 +01:00
Dan Brown
f88330202b Added test to cover secure restricted functionality 2022-09-02 14:03:23 +01:00
Dan Brown
f28ed0ef0b Fixed shelf covers being stored as 'cover_book'
Are now stored as 'cover_bookshelf' as expected.
Added a migrate to alter existing shelf cover image types.
2022-09-02 12:54:54 +01:00
Dan Brown
27ac122502 Started work on local_secure_restricted image option 2022-09-01 16:17:14 +01:00
Dan Brown
9da3130a12 Aligned bookshelf terminology to consistently be 'Shelf'
For #3553
EN only, other languages should be handled via CrowdIn
2022-09-01 14:55:35 +01:00
Dan Brown
1afc915aed Fixed missing nested list indent next to floated content
Fixes #3672
2022-09-01 13:11:59 +01:00
Dan Brown
34c63e1c30 Added test & update to prevent page creation w/ empty slug
Caused by changes to page repo in reference work,
This adds back in the slug generate although at a more central place.
Adds a test case to cover the problematic scenario.
2022-09-01 12:53:34 +01:00
Dan Brown
f092c97748 Fixed lack of url reference updating on book child move 2022-08-30 22:12:52 +01:00
Dan Brown
9153be963d Added book child reference handling on book url change
Closes #3683
2022-08-30 22:00:32 +01:00
Dan Brown
1cc7c649dc Applied StyleCi changes, updated php deps 2022-08-29 17:46:41 +01:00
Dan Brown
e537d0c4e8 Merge pull request #3656 from BookStackApp/x_linking
Link reference tracking & updating
2022-08-29 17:45:05 +01:00
Dan Brown
961e418cb7 Fixed phpstan wanring about usage of static 2022-08-29 17:39:50 +01:00
Dan Brown
6edf2c155d Added maintenance action to regenerate references 2022-08-29 17:30:26 +01:00
Dan Brown
401c156687 Merge pull request #3616 from BookStackApp/oidc_group_sync
Added OIDC group sync functionality
2022-08-25 11:17:18 +01:00
Dan Brown
760eff397f Updated API docs with better request format explanation
Explained the content-types accepted by BookStack.
Made it clear that 'Content-Type' is expected on requests.
Added example to shown how to achieve more complex formats using
non-json requests.
Also added link to api-scripts repo.

Related to #3666 and #3652
2022-08-23 17:05:42 +01:00
Dan Brown
d134639eca Doubled default revision limit
Due to potential increase of revision entries due to auto-changes.
2022-08-23 16:32:07 +01:00
Dan Brown
b86ee6d252 Rolled out reference link updating logic usage
Added test to cover updating of content on reference url change
2022-08-21 18:05:19 +01:00
Dan Brown
0dbf08453f Built out cross link replacer, not yet tested 2022-08-21 11:29:34 +01:00
Dan Brown
26ccb7b644 Started work on reference on-change-updates
Refactored out revision-specific actions within PageRepo for
organisition and re-use for cross-linking work.
2022-08-20 21:09:07 +01:00
Dan Brown
f634b4ea57 Added entity meta link to reference page
Not totally happy with implementation as is requires extra service to be
injected to core controllers, but does the job.
Included test to cover.
Updated some controller properties to be typed while there.
2022-08-20 12:07:38 +01:00
Dan Brown
d198332d3c Rolled out reference pages to all entities, added testing
Including testing to check permissions applied to listed references.
2022-08-19 22:40:44 +01:00
Dan Brown
d5465726e2 Added inbound references listing for pages 2022-08-19 13:14:43 +01:00
Dan Brown
bbe504c559 Added reference handling on page actions
Page update/create/restore/clone/delete.
Added a couple of tests to cover a couple of those.
2022-08-17 17:37:27 +01:00
Dan Brown
3290ab3ac9 Added regenerate-references command test
Also updated model resolvers to only fetch model ID, to prevent bringing
back way more data from database than desired.
2022-08-17 16:59:23 +01:00
Dan Brown
5d29d0cc7b Added reference storage system, and command to re-index
Also re-named/orgranized some files for this, to make them "References"
specific instead of a subset of "Util".
2022-08-17 14:40:14 +01:00
Dan Brown
344b3a3615 Added system to extract model references from HTML content
For the start of a managed cross-linking system.
2022-08-16 13:23:53 +01:00
Dan Brown
837fd74bf6 Refactored search-based code to its own folder
Also applied StyleCI changes
2022-08-16 11:28:05 +01:00
Dan Brown
2b06e86d53 Merge pull request #3653 from krsriq/patch-1
Fix typos
2022-08-15 22:31:49 +01:00
Daniel Schmelz
9041e25476 Fix typos 2022-08-15 22:41:44 +02:00
Dan Brown
1fdf854ea7 Updated version and assets for release v22.07.3 2022-08-11 15:17:06 +01:00
Dan Brown
e9c9792cb9 Merge branch 'development' into release 2022-08-11 15:16:34 +01:00
Dan Brown
d6235bcf92 Merge branch '3636-security-patch' into development 2022-08-11 15:15:19 +01:00
Dan Brown
6a3f4f5e79 Updated translator attribution pre v22.07.3 release 2022-08-11 13:17:18 +01:00
Dan Brown
7b100ef361 Merge branch 'persian_translate_22_08_10' into development 2022-08-11 13:15:15 +01:00
Dan Brown
443415ea0d New Crowdin updates (#3635) 2022-08-11 13:12:55 +01:00
Dan Brown
e02bd5e57e Added content security section to the api docs
Related to #3636
2022-08-11 10:49:45 +01:00
Dan Brown
5f7cd735ea Added content filtering of tags with javascript or data in values attr
Case would be blocked by CSP but adding for cases where CSP may not be
active when content taken externally.

For #3636
2022-08-11 10:28:32 +01:00
samad hassan allafi
89ff0d43bb Completion of Persian translation 2022-08-10 2022-08-10 22:55:31 +04:30
Dan Brown
375abca1ee Merge pull request #3632 from BookStackApp/ownable_permission_fix
Fixed failed permission checks due to non-loaded fields
2022-08-10 17:59:46 +01:00
Dan Brown
031c67ba58 Reduced the memory usage, db queries and cache hits loading revisions
Updated revision listing to only fetch required fields, massively
reducing memory usage by not loading content.
This also updates user avatar handling to effectively cache the avatar
url within request to avoid re-searching from cache, which may improve
performance of others areas of the application.
This also upates handling of the revisions list view to extract table
row to its own view to break things down a bit.

For #3633
2022-08-10 17:50:35 +01:00
Dan Brown
764489e30b Improved WYSWYG editor code block layout update
To help prevent against empty areas during inital empty-cache loads.
This delays the original layout update a little to give time for the
layout to render as expected.

For #3637
2022-08-10 13:51:54 +01:00
Dan Brown
16eedc8264 Fixed failed permission checks due to non-loaded fields
Added additional exceptions to prevent such cases in the future, so
that they are caught in dev ideally.
Added test case specifically for reported favourite scenario.
2022-08-10 08:06:48 +01:00
Dan Brown
5ae524c25a Updated version and assets for release v22.07.2 2022-08-09 13:55:52 +01:00
Dan Brown
0d7287fc8b Merge branch 'development' into release 2022-08-09 13:55:40 +01:00
Dan Brown
219da9da9b Updated translator attribution before release v22.07.2 2022-08-09 13:55:26 +01:00
Dan Brown
38ce54ea0c Merge pull request #3630 from BookStackApp/export_template_parts
Export template partials
2022-08-09 13:51:24 +01:00
Dan Brown
97ec560282 Added test to cover export body start/end partial usage 2022-08-09 13:49:42 +01:00
Dan Brown
06b5a83d8f Added convenience theme system partials for export layouts
To allow easier additions to start/end of body tag in export formats.
2022-08-09 13:46:52 +01:00
Dan Brown
45dc28ba2a Applied latest styleci changes 2022-08-09 13:26:45 +01:00
Dan Brown
6e0a7344fa Added revision activity types to system and audit log
Closes #3628
2022-08-09 13:25:18 +01:00
Dan Brown
7fa934e7f2 New Crowdin updates (#3625) 2022-08-09 13:00:39 +01:00
Dan Brown
a90446796a Fixed issue preventing selection of activity type in audit log
For #3623
2022-08-09 12:58:10 +01:00
Dan Brown
4209f27f1a Set a fairly sensible limit on user name validation
Also updated controller properties with types within modified files.
Related to #3614
2022-08-09 12:40:59 +01:00
Dan Brown
89ec9a5081 Sprinkled in some user language validation
For #3615
2022-08-04 17:24:04 +01:00
Dan Brown
b987bea37a Added OIDC group sync functionality
Is generally aligned with out SAML2 group sync functionality, but for
OIDC based upon feedback in #3004.
Neeeded the tangental addition of being able to define custom scopes on
the initial auth request as some systems use this to provide additional
id token claims such as groups.

Includes tests to cover.
Tested live using Okta.
2022-08-02 16:56:56 +01:00
Dan Brown
e77c96f6b7 Updated version and assets for release v22.07.1 2022-08-02 11:47:25 +01:00
Dan Brown
9b8a10dd3a Merge branch 'development' into release 2022-08-02 11:47:08 +01:00
Dan Brown
42f4c9afae New Crowdin updates (#3605) 2022-08-02 11:31:24 +01:00
Dan Brown
8d6071cb84 Updated cache busting for tinymce library import
Changes from a manual cache buster string to a app-version-based cache
buster, as per our other scripts and styles.

To address #3611
2022-08-02 11:17:02 +01:00
1182 changed files with 30021 additions and 15981 deletions

View File

@@ -80,6 +80,9 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
# Command to use when email is sent via sendmail
MAIL_SENDMAIL_COMMAND="/usr/sbin/sendmail -bs"
# Cache & Session driver to use
# Can be 'file', 'database', 'memcached' or 'redis'
CACHE_DRIVER=file
@@ -263,7 +266,12 @@ OIDC_ISSUER_DISCOVER=false
OIDC_PUBLIC_KEY=null
OIDC_AUTH_ENDPOINT=null
OIDC_TOKEN_ENDPOINT=null
OIDC_ADDITIONAL_SCOPES=null
OIDC_DUMP_USER_DETAILS=false
OIDC_USER_TO_GROUPS=false
OIDC_GROUPS_CLAIM=groups
OIDC_REMOVE_FROM_GROUPS=false
OIDC_EXTERNAL_ID_CLAIM=sub
# Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option
@@ -295,7 +303,7 @@ APP_DEFAULT_DARK_MODE=false
# Page revision limit
# Number of page revisions to keep in the system before deleting old revisions.
# If set to 'false' a limit will not be enforced.
REVISION_LIMIT=50
REVISION_LIMIT=100
# Recycle Bin Lifetime
# The number of days that content will remain in the recycle bin before

View File

@@ -56,6 +56,7 @@ Name :: Languages
@arcoai :: Spanish
@Jokuna :: Korean
@smartshogu :: German; German Informal
@samadha56 :: Persian
cipi1965 :: Italian
Mykola Ronik (Mantikor) :: Ukrainian
furkanoyk :: Turkish
@@ -137,7 +138,7 @@ Xiphoseer :: German
MerlinSVK (merlinsvk) :: Slovak
Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
MatthieuParis :: French
Douradinho :: Portuguese, Brazilian
Douradinho :: Portuguese, Brazilian; Portuguese
Gaku Yaguchi (tama11) :: Japanese
johnroyer :: Chinese Traditional
jackaaa :: Chinese Traditional
@@ -175,7 +176,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: Dutch; Turkish
REMOVED_USER :: ; French; Dutch; Turkish
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -267,3 +268,55 @@ Filip Antala (AntalaFilip) :: Slovak
mcgong (GongMingCai) :: Chinese Simplified; Chinese Traditional
Nanang Setia Budi (sefidananang) :: Indonesian
Андрей Павлов (andrei.pavlov) :: Russian
Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian
Ji-Hyeon Gim (PotatoGim) :: Korean
Mihai Ochian (soulstorm19) :: Romanian
HeartCore :: German Informal; German
simon.pct :: French
okaeiz :: Persian
Naoto Ishikawa (na3shkw) :: Japanese
sdhadi :: Persian
DerLinkman (derlinkman) :: German; German Informal
TurnArabic :: Arabic
Martin Sebek (sebekmartin) :: Czech
Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
digilady :: Greek
Linus (LinusOP) :: Swedish
Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian
RandomUser0815 :: German Informal; German
Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
구인회 (laskdjlaskdj12) :: Korean
LiZerui (CNLiZerui) :: Chinese Traditional
Fabrice Boyer (FabriceBoyer) :: French
mikael (bitcanon) :: Swedish
Matthias Mai (schnapsidee) :: German; German Informal
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
Jan Mitrof (jan.kachlik) :: Czech
edwardsmirnov :: Russian
Mr_OSS117 :: French
shotu :: French
Cesar_Lopez_Aguillon :: Spanish
bdewoop :: German
dina davoudi (dina.davoudi) :: Persian
Angelos Chouvardas (achouvardas) :: Greek
rndrss :: Portuguese, Brazilian
rirac294 :: Russian
David Furman (thefourCraft) :: Hebrew
Pafzedog :: French
Yllelder :: Spanish
Adrian Ocneanu (aocneanu) :: Romanian
Eduardo Castanho (EduardoCastanho) :: Portuguese
VIET NAM VPS (vietnamvps) :: Vietnamese
m4tthi4s :: French
toras9000 :: Japanese
pathab :: German
MichelSchoon85 :: Dutch
Jøran Haugli (haugli92) :: Norwegian Bokmal
Vasileios Kouvelis (VasilisKouvelis) :: Greek
Dremski :: Bulgarian
Frédéric SENE (nothingfr) :: French
bendem :: French
kostasdizas :: Greek
Ricardo Schroeder (brownstone666) :: Portuguese, Brazilian
Eitan MG (EitanMG) :: Hebrew
Robin Flikkema (RobinFlikkema) :: Dutch

View File

@@ -1,36 +1,34 @@
name: phpstan
name: analyse-php
on: [push, pull_request]
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.4']
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
php-version: 8.1
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
key: ${{ runner.os }}-composer-8.1
restore-keys: ${{ runner.os }}-composer-
- name: Install composer dependencies
run: composer install --prefer-dist --no-interaction --ansi
- name: Run PHPStan
run: php${{ matrix.php }} ./vendor/bin/phpstan analyse --memory-limit=2G
- name: Run static analysis check
run: composer check-static

19
.github/workflows/lint-php.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: lint-php
on: [push, pull_request]
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.1
tools: phpcs
- name: Run formatting check
run: composer lint

View File

@@ -5,10 +5,10 @@ on: [push, pull_request]
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['7.4', '8.0', '8.1']
php: ['8.0', '8.1', '8.2']
steps:
- uses: actions/checkout@v1
@@ -21,13 +21,14 @@ jobs:
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
restore-keys: ${{ runner.os }}-composer-
- name: Start MySQL
run: |

View File

@@ -1,14 +1,14 @@
name: phpunit
name: test-php
on: [push, pull_request]
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['7.4', '8.0', '8.1']
php: ['8.0', '8.1', '8.2']
steps:
- uses: actions/checkout@v1
@@ -16,18 +16,19 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap
extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
restore-keys: ${{ runner.os }}-composer-
- name: Start Database
run: |
@@ -48,5 +49,5 @@ jobs:
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
- name: phpunit
- name: Run PHP tests
run: php${{ matrix.php }} ./vendor/bin/phpunit

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ yarn-error.log
/public/js/*.map
/public/bower
/public/build/
/public/favicon.ico
/storage/images
_ide_helper.php
/storage/debugbar

View File

@@ -1,7 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015-present, Dan Brown and the BookStack Project contributors
https://github.com/BookStackApp/BookStack/graphs/contributors
Copyright (c) 2015-2023, 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

@@ -2,10 +2,12 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Str;
@@ -40,6 +42,12 @@ class Activity extends Model
return $this->belongsTo(User::class);
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
}
/**
* Returns text from the language files, Looks up by using the activity key.
*/

View File

@@ -29,6 +29,9 @@ class ActivityType
const COMMENTED_ON = 'commented_on';
const PERMISSIONS_UPDATE = 'permissions_update';
const REVISION_RESTORE = 'revision_restore';
const REVISION_DELETE = 'revision_delete';
const SETTINGS_UPDATE = 'settings_update';
const MAINTENANCE_ACTION_RUN = 'maintenance_action_run';

View File

@@ -2,7 +2,9 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Favourite extends Model
@@ -16,4 +18,10 @@ class Favourite extends Model
{
return $this->morphTo();
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'favouritable_id')
->whereColumn('favourites.favouritable_type', '=', 'joint_permissions.entity_type');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace BookStack\Actions\Queries;
use BookStack\Actions\Webhook;
use BookStack\Util\SimpleListOptions;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Get all the webhooks in the system in a paginated format.
*/
class WebhooksAllPaginatedAndSorted
{
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
{
$query = Webhook::query()->select(['*'])
->withCount(['trackedEvents'])
->orderBy($listOptions->getSort(), $listOptions->getOrder());
if ($listOptions->getSearch()) {
$term = '%' . $listOptions->getSearch() . '%';
$query->where(function ($query) use ($term) {
$query->where('name', 'like', $term)
->orWhere('endpoint', 'like', $term);
});
}
return $query->paginate($count);
}
}

View File

@@ -2,8 +2,10 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
@@ -27,6 +29,12 @@ class Tag extends Model
return $this->morphTo('entity');
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('tags.entity_type', '=', 'joint_permissions.entity_type');
}
/**
* Get a full URL to start a tag name search for this tag name.
*/

View File

@@ -4,24 +4,29 @@ namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Models\Entity;
use BookStack\Util\SimpleListOptions;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class TagRepo
{
protected PermissionApplicator $permissions;
public function __construct(PermissionApplicator $permissions)
{
$this->permissions = $permissions;
public function __construct(
protected PermissionApplicator $permissions
) {
}
/**
* Start a query against all tags in the system.
*/
public function queryWithTotals(string $searchTerm, string $nameFilter): Builder
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
{
$searchTerm = $listOptions->getSearch();
$sort = $listOptions->getSort();
if ($sort === 'name' && $nameFilter) {
$sort = 'value';
}
$query = Tag::query()
->select([
'name',
@@ -32,7 +37,7 @@ 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($nameFilter ? 'value' : 'name');
->orderBy($sort, $listOptions->getOrder());
if ($nameFilter) {
$query->where('name', '=', $nameFilter);
@@ -57,21 +62,21 @@ class TagRepo
* Get tag name suggestions from scanning existing tag names.
* If no search term is given the 50 most popular tag names are provided.
*/
public function getNameSuggestions(?string $searchTerm): Collection
public function getNameSuggestions(string $searchTerm): Collection
{
$query = Tag::query()
->select('*', DB::raw('count(*) as count'))
->groupBy('name');
if ($searchTerm) {
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'asc');
} else {
$query = $query->orderBy('count', 'desc')->take(50);
}
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['name'])->pluck('name');
return $query->pluck('name');
}
/**
@@ -79,10 +84,11 @@ class TagRepo
* If no search is given the 50 most popular values are provided.
* Passing a tagName will only find values for a tags with a particular name.
*/
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
public function getValueSuggestions(string $searchTerm, string $tagName): Collection
{
$query = Tag::query()
->select('*', DB::raw('count(*) as count'))
->where('value', '!=', '')
->groupBy('value');
if ($searchTerm) {
@@ -97,7 +103,7 @@ class TagRepo
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['value'])->pluck('value');
return $query->pluck('value');
}
/**

View File

@@ -2,8 +2,10 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
@@ -28,6 +30,12 @@ class View extends Model
return $this->morphTo();
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'viewable_id')
->whereColumn('views.viewable_type', '=', 'joint_permissions.entity_type');
}
/**
* Increment the current user's view count for the given viewable model.
*/

View File

@@ -22,10 +22,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
*/
class Webhook extends Model implements Loggable
{
protected $fillable = ['name', 'endpoint', 'timeout'];
use HasFactory;
protected $fillable = ['name', 'endpoint', 'timeout'];
protected $casts = [
'last_called_at' => 'datetime',
'last_errored_at' => 'datetime',

View File

@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Model;
*/
class WebhookTrackedEvent extends Model
{
protected $fillable = ['event'];
use HasFactory;
protected $fillable = ['event'];
}

View File

@@ -0,0 +1,107 @@
<?php
namespace BookStack\Api;
use BookStack\Entities\Models\Entity;
class ApiEntityListFormatter
{
/**
* The list to be formatted.
* @var Entity[]
*/
protected $list = [];
/**
* The fields to show in the formatted data.
* Can be a plain string array item for a direct model field (If existing on model).
* If the key is a string, with a callable value, the return value of the callable
* will be used for the resultant value. A null return value will omit the property.
* @var array<string|int, string|callable>
*/
protected $fields = [
'id', 'name', 'slug', 'book_id', 'chapter_id',
'draft', 'template', 'created_at', 'updated_at',
];
public function __construct(array $list)
{
$this->list = $list;
// Default dynamic fields
$this->withField('url', fn(Entity $entity) => $entity->getUrl());
}
/**
* Add a field to be used in the formatter, with the property using the given
* name and value being the return type of the given callback.
*/
public function withField(string $property, callable $callback): self
{
$this->fields[$property] = $callback;
return $this;
}
/**
* Show the 'type' property in the response reflecting the entity type.
* EG: page, chapter, bookshelf, book
* To be included in results with non-pre-determined types.
*/
public function withType(): self
{
$this->withField('type', fn(Entity $entity) => $entity->getType());
return $this;
}
/**
* Include tags in the formatted data.
*/
public function withTags(): self
{
$this->withField('tags', fn(Entity $entity) => $entity->tags);
return $this;
}
/**
* Format the data and return an array of formatted content.
* @return array[]
*/
public function format(): array
{
$results = [];
foreach ($this->list as $item) {
$results[] = $this->formatSingle($item);
}
return $results;
}
/**
* Format a single entity item to a plain array.
*/
protected function formatSingle(Entity $entity): array
{
$result = [];
$values = (clone $entity)->toArray();
foreach ($this->fields as $field => $callback) {
if (is_string($callback)) {
$field = $callback;
if (!isset($values[$field])) {
continue;
}
$value = $values[$field];
} else {
$value = $callback($entity);
if (is_null($value)) {
continue;
}
}
$result[$field] = $value;
}
return $result;
}
}

View File

@@ -2,24 +2,31 @@
namespace BookStack\Api;
use BookStack\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ListingResponseBuilder
{
protected $query;
protected $request;
protected $fields;
protected Builder $query;
protected Request $request;
/**
* @var string[]
*/
protected array $fields;
/**
* @var array<callable>
*/
protected $resultModifiers = [];
protected array $resultModifiers = [];
protected $filterOperators = [
/**
* @var array<string, string>
*/
protected array $filterOperators = [
'eq' => '=',
'ne' => '!=',
'gt' => '>',
@@ -63,9 +70,9 @@ class ListingResponseBuilder
/**
* Add a callback to modify each element of the results.
*
* @param (callable(Model)) $modifier
* @param (callable(Model): void) $modifier
*/
public function modifyResults($modifier): void
public function modifyResults(callable $modifier): void
{
$this->resultModifiers[] = $modifier;
}

View File

@@ -105,7 +105,7 @@ class LdapService
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
'avatar'=> $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
];
if ($this->config['dump_user_details']) {

View File

@@ -5,6 +5,7 @@ namespace BookStack\Auth\Access;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\Mfa\MfaSession;
use BookStack\Auth\User;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
@@ -149,6 +150,7 @@ class LoginService
* May interrupt the flow if extra authentication requirements are imposed.
*
* @throws StoppedAuthenticationException
* @throws LoginAttemptException
*/
public function attempt(array $credentials, string $method, bool $remember = false): bool
{

View File

@@ -67,11 +67,10 @@ class OidcJwtSigningKey
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
}
if (empty($jwk['use'])) {
throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected');
}
if ($jwk['use'] !== 'sig') {
// 'use' is optional for a JWK but we assume 'sig' where no value exists since that's what
// the OIDC discovery spec infers since 'sig' MUST be set if encryption keys come into play.
$use = $jwk['use'] ?? 'sig';
if ($use !== 'sig') {
throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
}

View File

@@ -30,6 +30,11 @@ class OidcOAuthProvider extends AbstractProvider
*/
protected $tokenEndpoint;
/**
* Scopes to use for the OIDC authorization call.
*/
protected array $scopes = ['openid', 'profile', 'email'];
/**
* Returns the base URL for authorizing a client.
*/
@@ -54,6 +59,15 @@ class OidcOAuthProvider extends AbstractProvider
return '';
}
/**
* Add an additional scope to this provider upon the default.
*/
public function addScope(string $scope): void
{
$this->scopes[] = $scope;
$this->scopes = array_unique($this->scopes);
}
/**
* Returns the default scopes used by this provider.
*
@@ -62,7 +76,7 @@ class OidcOAuthProvider extends AbstractProvider
*/
protected function getDefaultScopes(): array
{
return ['openid', 'profile', 'email'];
return $this->scopes;
}
/**

View File

@@ -15,40 +15,17 @@ use Psr\Http\Client\ClientInterface;
*/
class OidcProviderSettings
{
/**
* @var string
*/
public $issuer;
/**
* @var string
*/
public $clientId;
/**
* @var string
*/
public $clientSecret;
/**
* @var string
*/
public $redirectUri;
/**
* @var string
*/
public $authorizationEndpoint;
/**
* @var string
*/
public $tokenEndpoint;
public string $issuer;
public string $clientId;
public string $clientSecret;
public ?string $redirectUri;
public ?string $authorizationEndpoint;
public ?string $tokenEndpoint;
/**
* @var string[]|array[]
*/
public $keys = [];
public ?array $keys = [];
public function __construct(array $settings)
{
@@ -164,9 +141,10 @@ class OidcProviderSettings
protected function filterKeys(array $keys): array
{
return array_filter($keys, function (array $key) {
$alg = $key['alg'] ?? null;
$alg = $key['alg'] ?? 'RS256';
$use = $key['use'] ?? 'sig';
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
});
}

View File

@@ -2,20 +2,18 @@
namespace BookStack\Auth\Access\Oidc;
use function auth;
use BookStack\Auth\Access\GroupSyncService;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use function config;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Http\Client\ClientInterface as HttpClient;
use function trans;
use function url;
/**
* Class OpenIdConnectService
@@ -26,15 +24,21 @@ class OidcService
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected HttpClient $httpClient;
protected GroupSyncService $groupService;
/**
* OpenIdService constructor.
*/
public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
{
public function __construct(
RegistrationService $registrationService,
LoginService $loginService,
HttpClient $httpClient,
GroupSyncService $groupService
) {
$this->registrationService = $registrationService;
$this->loginService = $loginService;
$this->httpClient = $httpClient;
$this->groupService = $groupService;
}
/**
@@ -48,7 +52,6 @@ class OidcService
{
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
return [
'url' => $provider->getAuthorizationUrl(),
'state' => $provider->getState(),
@@ -117,10 +120,31 @@ class OidcService
*/
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{
return new OidcOAuthProvider($settings->arrayForProvider(), [
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
'httpClient' => $this->httpClient,
'optionProvider' => new HttpBasicAuthOptionProvider(),
]);
foreach ($this->getAdditionalScopes() as $scope) {
$provider->addScope($scope);
}
return $provider;
}
/**
* Get any user-defined addition/custom scopes to apply to the authentication request.
*
* @return string[]
*/
protected function getAdditionalScopes(): array
{
$scopeConfig = $this->config()['additional_scopes'] ?: '';
$scopeArr = explode(',', $scopeConfig);
$scopeArr = array_map(fn (string $scope) => trim($scope), $scopeArr);
return array_filter($scopeArr);
}
/**
@@ -145,19 +169,43 @@ class OidcService
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}
* @return array{name: string, email: string, external_id: string, groups: string[]}
*/
protected function getUserDetails(OidcIdToken $token): array
{
$id = $token->getClaim('sub');
$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),
];
}
@@ -209,6 +257,12 @@ class OidcService
throw new OidcException($exception->getMessage());
}
if ($this->shouldSyncGroups()) {
$groups = $userDetails['groups'];
$detachExisting = $this->config()['remove_from_groups'];
$this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
}
$this->loginService->login($user, 'oidc');
return $user;
@@ -221,4 +275,12 @@ class OidcService
{
return config('oidc');
}
/**
* Check if groups should be synced.
*/
protected function shouldSyncGroups(): bool
{
return $this->config()['user_to_groups'] !== false;
}
}

View File

@@ -20,14 +20,11 @@ use OneLogin\Saml2\ValidationError;
*/
class Saml2Service
{
protected $config;
protected $registrationService;
protected $loginService;
protected $groupSyncService;
protected array $config;
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected GroupSyncService $groupSyncService;
/**
* Saml2Service constructor.
*/
public function __construct(
RegistrationService $registrationService,
LoginService $loginService,
@@ -109,9 +106,10 @@ class Saml2Service
$errors = $toolkit->getErrors();
if (!empty($errors)) {
throw new Error(
'Invalid ACS Response: ' . implode(', ', $errors)
);
$reason = $toolkit->getLastErrorReason();
$message = 'Invalid ACS Response; Errors: ' . implode(', ', $errors);
$message .= $reason ? "; Reason: {$reason}" : '';
throw new Error($message);
}
if (!$toolkit->isAuthenticated()) {
@@ -168,7 +166,7 @@ class Saml2Service
*/
public function metadata(): string
{
$toolKit = $this->getToolkit();
$toolKit = $this->getToolkit(true);
$settings = $toolKit->getSettings();
$metadata = $settings->getSPMetadata();
$errors = $settings->validateMetadata($metadata);
@@ -189,7 +187,7 @@ class Saml2Service
* @throws Error
* @throws Exception
*/
protected function getToolkit(): Auth
protected function getToolkit(bool $spOnly = false): Auth
{
$settings = $this->config['onelogin'];
$overrides = $this->config['onelogin_overrides'] ?? [];
@@ -199,14 +197,14 @@ class Saml2Service
}
$metaDataSettings = [];
if ($this->config['autoload_from_metadata']) {
if (!$spOnly && $this->config['autoload_from_metadata']) {
$metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']);
}
$spSettings = $this->loadOneloginServiceProviderDetails();
$settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
return new Auth($settings);
return new Auth($settings, $spOnly);
}
/**

View File

@@ -2,20 +2,41 @@
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property int $role_id
* @property int $entity_id
* @property string $entity_type
* @property boolean $view
* @property boolean $create
* @property boolean $update
* @property boolean $delete
*/
class EntityPermission extends Model
{
protected $fillable = ['role_id', 'action'];
public const PERMISSIONS = ['view', 'create', 'update', 'delete'];
protected $fillable = ['role_id', 'view', 'create', 'update', 'delete'];
public $timestamps = false;
/**
* Get all this restriction's attached entity.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
* Get this restriction's attached entity.
*/
public function restrictable()
public function restrictable(): MorphTo
{
return $this->morphTo('restrictable');
}
/**
* Get the role assigned to this entity permission.
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Builder;
class EntityPermissionEvaluator
{
protected string $action;
public function __construct(string $action)
{
$this->action = $action;
}
public function evaluateEntityForUser(Entity $entity, array $userRoleIds): ?bool
{
if ($this->isUserSystemAdmin($userRoleIds)) {
return true;
}
$typeIdChain = $this->gatherEntityChainTypeIds(SimpleEntityData::fromEntity($entity));
$relevantPermissions = $this->getPermissionsMapByTypeId($typeIdChain, [...$userRoleIds, 0]);
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
$status = $this->evaluatePermitsByType($permitsByType);
return is_null($status) ? null : $status === PermissionStatus::IMPLICIT_ALLOW || $status === PermissionStatus::EXPLICIT_ALLOW;
}
/**
* @param array<string, array<string, int>> $permitsByType
*/
protected function evaluatePermitsByType(array $permitsByType): ?int
{
// Return grant or reject from role-level if exists
if (count($permitsByType['role']) > 0) {
return max($permitsByType['role']) ? PermissionStatus::EXPLICIT_ALLOW : PermissionStatus::EXPLICIT_DENY;
}
// Return fallback permission if exists
if (count($permitsByType['fallback']) > 0) {
return $permitsByType['fallback'][0] ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
}
return null;
}
/**
* @param string[] $typeIdChain
* @param array<string, EntityPermission[]> $permissionMapByTypeId
* @return array<string, array<string, int>>
*/
protected function collapseAndCategorisePermissions(array $typeIdChain, array $permissionMapByTypeId): array
{
$permitsByType = ['fallback' => [], 'role' => []];
foreach ($typeIdChain as $typeId) {
$permissions = $permissionMapByTypeId[$typeId] ?? [];
foreach ($permissions as $permission) {
$roleId = $permission->role_id;
$type = $roleId === 0 ? 'fallback' : 'role';
if (!isset($permitsByType[$type][$roleId])) {
$permitsByType[$type][$roleId] = $permission->{$this->action};
}
}
if (isset($permitsByType['fallback'][0])) {
break;
}
}
return $permitsByType;
}
/**
* @param string[] $typeIdChain
* @return array<string, EntityPermission[]>
*/
protected function getPermissionsMapByTypeId(array $typeIdChain, array $filterRoleIds): array
{
$query = EntityPermission::query()->where(function (Builder $query) use ($typeIdChain) {
foreach ($typeIdChain as $typeId) {
$query->orWhere(function (Builder $query) use ($typeId) {
[$type, $id] = explode(':', $typeId);
$query->where('entity_type', '=', $type)
->where('entity_id', '=', $id);
});
}
});
if (!empty($filterRoleIds)) {
$query->where(function (Builder $query) use ($filterRoleIds) {
$query->whereIn('role_id', [...$filterRoleIds, 0]);
});
}
$relevantPermissions = $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
$map = [];
foreach ($relevantPermissions as $permission) {
$key = $permission->entity_type . ':' . $permission->entity_id;
if (!isset($map[$key])) {
$map[$key] = [];
}
$map[$key][] = $permission;
}
return $map;
}
/**
* @return string[]
*/
protected function gatherEntityChainTypeIds(SimpleEntityData $entity): array
{
// The array order here is very important due to the fact we walk up the chain
// elsewhere in the class. Earlier items in the chain have higher priority.
$chain = [$entity->type . ':' . $entity->id];
if ($entity->type === 'page' && $entity->chapter_id) {
$chain[] = 'chapter:' . $entity->chapter_id;
}
if ($entity->type === 'page' || $entity->type === 'chapter') {
$chain[] = 'book:' . $entity->book_id;
}
return $chain;
}
protected function isUserSystemAdmin($userRoleIds): bool
{
$adminRoleId = Role::getSystemRole('admin')->id;
return in_array($adminRoleId, $userRoleIds);
}
}

View File

@@ -19,11 +19,6 @@ use Illuminate\Support\Facades\DB;
*/
class JointPermissionBuilder
{
/**
* @var array<string, array<int, SimpleEntityData>>
*/
protected $entityCache;
/**
* Re-generate all entity permission from scratch.
*/
@@ -40,7 +35,7 @@ class JointPermissionBuilder
});
// Chunk through all bookshelves
Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
Bookshelf::query()->withTrashed()->select(['id', 'owned_by'])
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
$this->createManyJointPermissions($shelves->all(), $roles);
});
@@ -92,58 +87,24 @@ class JointPermissionBuilder
});
// Chunk through all bookshelves
Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
Bookshelf::query()->select(['id', 'owned_by'])
->chunk(50, function ($shelves) use ($roles) {
$this->createManyJointPermissions($shelves->all(), $roles);
});
}
/**
* Prepare the local entity cache and ensure it's empty.
*
* @param SimpleEntityData[] $entities
*/
protected function readyEntityCache(array $entities)
{
$this->entityCache = [];
foreach ($entities as $entity) {
if (!isset($this->entityCache[$entity->type])) {
$this->entityCache[$entity->type] = [];
}
$this->entityCache[$entity->type][$entity->id] = $entity;
}
}
/**
* Get a book via ID, Checks local cache.
*/
protected function getBook(int $bookId): SimpleEntityData
{
return $this->entityCache['book'][$bookId];
}
/**
* Get a chapter via ID, Checks local cache.
*/
protected function getChapter(int $chapterId): SimpleEntityData
{
return $this->entityCache['chapter'][$chapterId];
}
/**
* Get a query for fetching a book with its children.
*/
protected function bookFetchQuery(): Builder
{
return Book::query()->withTrashed()
->select(['id', 'restricted', 'owned_by'])->with([
->select(['id', 'owned_by'])->with([
'chapters' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
$query->withTrashed()->select(['id', 'owned_by', 'book_id']);
},
'pages' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
$query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']);
},
]);
}
@@ -214,14 +175,7 @@ class JointPermissionBuilder
$simpleEntities = [];
foreach ($entities as $entity) {
$attrs = $entity->getAttributes();
$simple = new SimpleEntityData();
$simple->id = $attrs['id'];
$simple->type = $entity->getMorphClass();
$simple->restricted = boolval($attrs['restricted'] ?? 0);
$simple->owned_by = $attrs['owned_by'] ?? 0;
$simple->book_id = $attrs['book_id'] ?? null;
$simple->chapter_id = $attrs['chapter_id'] ?? null;
$simple = SimpleEntityData::fromEntity($entity);
$simpleEntities[] = $simple;
}
@@ -231,31 +185,16 @@ class JointPermissionBuilder
/**
* Create & Save entity jointPermissions for many entities and roles.
*
* @param Entity[] $entities
* @param Entity[] $originalEntities
* @param Role[] $roles
*/
protected function createManyJointPermissions(array $originalEntities, array $roles)
{
$entities = $this->entitiesToSimpleEntities($originalEntities);
$this->readyEntityCache($entities);
$jointPermissions = [];
// Create a mapping of entity restricted statuses
$entityRestrictedMap = [];
foreach ($entities as $entity) {
$entityRestrictedMap[$entity->type . ':' . $entity->id] = $entity->restricted;
}
// Fetch related entity permissions
$permissions = $this->getEntityPermissionsForEntities($entities);
// Create a mapping of explicit entity permissions
$permissionMap = [];
foreach ($permissions as $permission) {
$key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id;
$isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
$permissionMap[$key] = $isRestricted;
}
$permissions = new MassEntityPermissionEvaluator($entities, 'view');
// Create a mapping of role permissions
$rolePermissionMap = [];
@@ -268,13 +207,14 @@ class JointPermissionBuilder
// Create Joint Permission Data
foreach ($entities as $entity) {
foreach ($roles as $role) {
$jointPermissions[] = $this->createJointPermissionData(
$jp = $this->createJointPermissionData(
$entity,
$role->getRawAttribute('id'),
$permissionMap,
$permissions,
$rolePermissionMap,
$role->system_name === 'admin'
);
$jointPermissions[] = $jp;
}
}
@@ -308,98 +248,45 @@ class JointPermissionBuilder
return $idsByType;
}
/**
* Get the entity permissions for all the given entities.
*
* @param SimpleEntityData[] $entities
*
* @return EntityPermission[]
*/
protected function getEntityPermissionsForEntities(array $entities): array
{
$idsByType = $this->entitiesToTypeIdMap($entities);
$permissionFetch = EntityPermission::query()
->where('action', '=', 'view')
->where(function (Builder $query) use ($idsByType) {
foreach ($idsByType as $type => $ids) {
$query->orWhere(function (Builder $query) use ($type, $ids) {
$query->where('restrictable_type', '=', $type)->whereIn('restrictable_id', $ids);
});
}
});
return $permissionFetch->get()->all();
}
/**
* Create entity permission data for an entity and role
* for a particular action.
*/
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, array $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, MassEntityPermissionEvaluator $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
{
// Ensure system admin role retains permissions
if ($isAdminRole) {
return $this->createJointPermissionDataArray($entity, $roleId, PermissionStatus::EXPLICIT_ALLOW, true);
}
// Return evaluated entity permission status if it has an affect.
$entityPermissionStatus = $permissionMap->evaluateEntityForRole($entity, $roleId);
if ($entityPermissionStatus !== null) {
return $this->createJointPermissionDataArray($entity, $roleId, $entityPermissionStatus, false);
}
// Otherwise default to the role-level permissions
$permissionPrefix = $entity->type . '-view';
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
if ($isAdminRole) {
return $this->createJointPermissionDataArray($entity, $roleId, true, true);
}
if ($entity->restricted) {
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId);
return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess);
}
if ($entity->type === 'book' || $entity->type === 'bookshelf') {
return $this->createJointPermissionDataArray($entity, $roleId, $roleHasPermission, $roleHasPermissionOwn);
}
// For chapters and pages, Check if explicit permissions are set on the Book.
$book = $this->getBook($entity->book_id);
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $roleId);
$hasPermissiveAccessToParents = !$book->restricted;
// For pages with a chapter, Check if explicit permissions are set on the Chapter
if ($entity->type === 'page' && $entity->chapter_id !== 0) {
$chapter = $this->getChapter($entity->chapter_id);
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
if ($chapter->restricted) {
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
}
}
return $this->createJointPermissionDataArray(
$entity,
$roleId,
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
);
}
/**
* Check for an active restriction in an entity map.
*/
protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool
{
$key = $entity->type . ':' . $entity->id . ':' . $roleId;
return $entityMap[$key] ?? false;
$status = $roleHasPermission ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
return $this->createJointPermissionDataArray($entity, $roleId, $status, $roleHasPermissionOwn);
}
/**
* Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion.
*/
protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, bool $permissionAll, bool $permissionOwn): array
protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, int $permissionStatus, bool $hasPermissionOwn): array
{
$ownPermissionActive = ($hasPermissionOwn && $permissionStatus !== PermissionStatus::EXPLICIT_DENY && $entity->owned_by);
return [
'entity_id' => $entity->id,
'entity_type' => $entity->type,
'has_permission' => $permissionAll,
'has_permission_own' => $permissionOwn,
'owned_by' => $entity->owned_by,
'role_id' => $roleId,
'entity_id' => $entity->id,
'entity_type' => $entity->type,
'role_id' => $roleId,
'status' => $permissionStatus,
'owner_id' => $ownPermissionActive ? $entity->owned_by : null,
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace BookStack\Auth\Permissions;
class MassEntityPermissionEvaluator extends EntityPermissionEvaluator
{
/**
* @var SimpleEntityData[]
*/
protected array $entitiesInvolved;
protected array $permissionMapCache;
public function __construct(array $entitiesInvolved, string $action)
{
$this->entitiesInvolved = $entitiesInvolved;
parent::__construct($action);
}
public function evaluateEntityForRole(SimpleEntityData $entity, int $roleId): ?int
{
$typeIdChain = $this->gatherEntityChainTypeIds($entity);
$relevantPermissions = $this->getPermissionMapByTypeIdForChainAndRole($typeIdChain, $roleId);
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
return $this->evaluatePermitsByType($permitsByType);
}
/**
* @param string[] $typeIdChain
* @return array<string, EntityPermission[]>
*/
protected function getPermissionMapByTypeIdForChainAndRole(array $typeIdChain, int $roleId): array
{
$allPermissions = $this->getPermissionMapByTypeIdAndRoleForAllInvolved();
$relevantPermissions = [];
// Filter down permissions to just those for current typeId
// and current roleID or fallback permissions.
foreach ($typeIdChain as $typeId) {
$relevantPermissions[$typeId] = [
...($allPermissions[$typeId][$roleId] ?? []),
...($allPermissions[$typeId][0] ?? [])
];
}
return $relevantPermissions;
}
/**
* @return array<string, array<int, EntityPermission[]>>
*/
protected function getPermissionMapByTypeIdAndRoleForAllInvolved(): array
{
if (isset($this->permissionMapCache)) {
return $this->permissionMapCache;
}
$entityTypeIdChain = [];
foreach ($this->entitiesInvolved as $entity) {
$entityTypeIdChain[] = $entity->type . ':' . $entity->id;
}
$permissionMap = $this->getPermissionsMapByTypeId($entityTypeIdChain, []);
// Manipulate permission map to also be keyed by roleId.
foreach ($permissionMap as $typeId => $permissions) {
$permissionMap[$typeId] = [];
foreach ($permissions as $permission) {
$roleId = $permission->getRawAttribute('role_id');
if (!isset($permissionMap[$typeId][$roleId])) {
$permissionMap[$typeId][$roleId] = [];
}
$permissionMap[$typeId][$roleId][] = $permission;
}
}
$this->permissionMapCache = $permissionMap;
return $this->permissionMapCache;
}
}

View File

@@ -4,7 +4,6 @@ namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Model;
@@ -34,7 +33,13 @@ class PermissionApplicator
$ownRolePermission = $user->can($fullPermission . '-own');
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
$isOwner = $user->id === $ownable->getAttribute($ownerField);
$ownableFieldVal = $ownable->getAttribute($ownerField);
if (is_null($ownableFieldVal)) {
throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
}
$isOwner = $user->id === $ownableFieldVal;
$hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
// Handle non entity specific jointPermissions
@@ -53,30 +58,9 @@ class PermissionApplicator
*/
protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool
{
$adminRoleId = Role::getSystemRole('admin')->id;
if (in_array($adminRoleId, $userRoleIds)) {
return true;
}
$this->ensureValidEntityAction($action);
$chain = [$entity];
if ($entity instanceof Page && $entity->chapter_id) {
$chain[] = $entity->chapter;
}
if ($entity instanceof Page || $entity instanceof Chapter) {
$chain[] = $entity->book;
}
foreach ($chain as $currentEntity) {
if ($currentEntity->restricted) {
return $currentEntity->permissions()
->whereIn('role_id', $userRoleIds)
->where('action', '=', $action)
->count() > 0;
}
}
return null;
return (new EntityPermissionEvaluator($action))->evaluateEntityForUser($entity, $userRoleIds);
}
/**
@@ -85,18 +69,16 @@ class PermissionApplicator
*/
public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
{
if (strpos($action, '-') !== false) {
throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
}
$this->ensureValidEntityAction($action);
$permissionQuery = EntityPermission::query()
->where('action', '=', $action)
->where($action, '=', true)
->whereIn('role_id', $this->getCurrentUserRoleIds());
if (!empty($entityClass)) {
/** @var Entity $entityInstance */
$entityInstance = app()->make($entityClass);
$permissionQuery = $permissionQuery->where('restrictable_type', '=', $entityInstance->getMorphClass());
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
}
$hasPermission = $permissionQuery->count() > 0;
@@ -112,10 +94,12 @@ class PermissionApplicator
{
return $query->where(function (Builder $parentQuery) {
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) {
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoleIds())
->where(function (Builder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
$permissionQuery->select(['entity_id', 'entity_type'])
->selectRaw('max(owner_id) as owner_id')
->selectRaw('max(status) as status')
->whereIn('role_id', $this->getCurrentUserRoleIds())
->groupBy(['entity_type', 'entity_id'])
->havingRaw('(status IN (1, 3) or (owner_id = ? and status != 2))', [$this->currentUser()->id]);
});
});
}
@@ -139,35 +123,23 @@ class PermissionApplicator
* Filter items that have entities set as a polymorphic relation.
* For simplicity, this will not return results attached to draft pages.
* Draft pages should never really have related items though.
*
* @param Builder|QueryBuilder $query
*/
public function restrictEntityRelationQuery($query, string $tableName, string $entityIdColumn, string $entityTypeColumn)
public function restrictEntityRelationQuery(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn): Builder
{
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$pageMorphClass = (new Page())->getMorphClass();
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails) {
/** @var Builder $permissionQuery */
$permissionQuery->select(['role_id'])->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
})->where(function ($query) use ($tableDetails, $pageMorphClass) {
/** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
return $this->restrictEntityQuery($query)
->where(function ($query) use ($tableDetails, $pageMorphClass) {
/** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
->where('pages.draft', '=', false);
});
});
return $q;
});
}
/**
@@ -179,49 +151,20 @@ class PermissionApplicator
public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder
{
$fullPageIdColumn = $tableName . '.' . $pageIdColumn;
$morphClass = (new Page())->getMorphClass();
$existsQuery = function ($permissionQuery) use ($fullPageIdColumn, $morphClass) {
/** @var Builder $permissionQuery */
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $fullPageIdColumn)
->where('joint_permissions.entity_type', '=', $morphClass)
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
return $this->restrictEntityQuery($query)
->where(function ($query) use ($fullPageIdColumn) {
/** @var Builder $query */
$query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullPageIdColumn)
->where('pages.draft', '=', false);
})->orWhereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullPageIdColumn)
->where('pages.draft', '=', true)
->where('pages.created_by', '=', $this->currentUser()->id);
});
};
$q = $query->where(function ($query) use ($existsQuery, $fullPageIdColumn) {
$query->whereExists($existsQuery)
->orWhere($fullPageIdColumn, '=', 0);
});
// Prevent visibility of non-owned draft pages
$q->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullPageIdColumn)
->where(function (QueryBuilder $query) {
$query->where('pages.draft', '=', false)
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
});
});
return $q;
}
/**
* Add the query for checking the given user id has permission
* within the join_permissions table.
*
* @param QueryBuilder|Builder $query
*/
protected function addJointHasPermissionCheck($query, int $userIdToCheck)
{
$query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
$query->where('joint_permissions.has_permission_own', '=', true)
->where('joint_permissions.owned_by', '=', $userIdToCheck);
});
});
}
/**
@@ -245,4 +188,16 @@ class PermissionApplicator
return $this->currentUser()->roles->pluck('id')->values()->all();
}
/**
* Ensure the given action is a valid and expected entity action.
* Throws an exception if invalid otherwise does nothing.
* @throws InvalidArgumentException
*/
protected function ensureValidEntityAction(string $action): void
{
if (!in_array($action, EntityPermission::PERMISSIONS)) {
throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Entity;
class PermissionFormData
{
protected Entity $entity;
public function __construct(Entity $entity)
{
$this->entity = $entity;
}
/**
* Get the permissions with assigned roles.
*/
public function permissionsWithRoles(): array
{
return $this->entity->permissions()
->with('role')
->where('role_id', '!=', 0)
->get()
->sortBy('role.display_name')
->all();
}
/**
* Get the roles that don't yet have specific permissions for the
* entity we're managing permissions for.
*/
public function rolesNotAssigned(): array
{
$assigned = $this->entity->permissions()->pluck('role_id');
return Role::query()
->where('system_name', '!=', 'admin')
->whereNotIn('id', $assigned)
->orderBy('display_name', 'asc')
->get()
->all();
}
/**
* Get the entity permission for the "Everyone Else" option.
*/
public function everyoneElseEntityPermission(): EntityPermission
{
/** @var ?EntityPermission $permission */
$permission = $this->entity->permissions()
->where('role_id', '=', 0)
->first();
return $permission ?? (new EntityPermission());
}
/**
* Get the "Everyone Else" role entry.
*/
public function everyoneElseRole(): Role
{
return (new Role())->forceFill([
'id' => 0,
'display_name' => trans('entities.permissions_role_everyone_else'),
'description' => trans('entities.permissions_role_everyone_else_desc'),
]);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace BookStack\Auth\Permissions;
class PermissionStatus
{
const IMPLICIT_DENY = 0;
const IMPLICIT_ALLOW = 1;
const EXPLICIT_DENY = 2;
const EXPLICIT_ALLOW = 3;
}

View File

@@ -12,11 +12,8 @@ use Illuminate\Database\Eloquent\Collection;
class PermissionsRepo
{
protected JointPermissionBuilder $permissionBuilder;
protected $systemRoles = ['admin', 'public'];
protected array $systemRoles = ['admin', 'public'];
/**
* PermissionsRepo constructor.
*/
public function __construct(JointPermissionBuilder $permissionBuilder)
{
$this->permissionBuilder = $permissionBuilder;
@@ -41,7 +38,7 @@ class PermissionsRepo
/**
* Get a role via its ID.
*/
public function getRoleById($id): Role
public function getRoleById(int $id): Role
{
return Role::query()->findOrFail($id);
}
@@ -52,10 +49,10 @@ class PermissionsRepo
public function saveNewRole(array $roleData): Role
{
$role = new Role($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
$role->save();
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$permissions = $roleData['permissions'] ?? [];
$this->assignRolePermissions($role, $permissions);
$this->permissionBuilder->rebuildForRole($role);
@@ -66,42 +63,45 @@ class PermissionsRepo
/**
* Updates an existing role.
* Ensure Admin role always have core permissions.
* Ensures Admin system role always have core permissions.
*/
public function updateRole($roleId, array $roleData)
public function updateRole($roleId, array $roleData): Role
{
$role = $this->getRoleById($roleId);
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
if (isset($roleData['permissions'])) {
$this->assignRolePermissions($role, $roleData['permissions']);
}
$role->fill($roleData);
$role->save();
$this->permissionBuilder->rebuildForRole($role);
Activity::add(ActivityType::ROLE_UPDATE, $role);
return $role;
}
/**
* Assign a list of permission names to the given role.
*/
protected function assignRolePermissions(Role $role, array $permissionNameArray = []): void
{
$permissions = [];
$permissionNameArray = array_values($permissionNameArray);
// Ensure the admin system role retains vital system permissions
if ($role->system_name === 'admin') {
$permissions = array_merge($permissions, [
$permissionNameArray = array_unique(array_merge($permissionNameArray, [
'users-manage',
'user-roles-manage',
'restrictions-manage-all',
'restrictions-manage-own',
'settings-manage',
]);
]));
}
$this->assignRolePermissions($role, $permissions);
$role->fill($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->save();
$this->permissionBuilder->rebuildForRole($role);
Activity::add(ActivityType::ROLE_UPDATE, $role);
}
/**
* Assign a list of permission names to a role.
*/
protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
{
$permissions = [];
$permissionNameArray = array_values($permissionNameArray);
if ($permissionNameArray) {
if (!empty($permissionNameArray)) {
$permissions = RolePermission::query()
->whereIn('name', $permissionNameArray)
->pluck('id')
@@ -114,13 +114,13 @@ class PermissionsRepo
/**
* Delete a role from the system.
* Check it's not an admin role or set as default before deleting.
* If an migration Role ID is specified the users assign to the current role
* If a migration Role ID is specified the users assign to the current role
* will be added to the role of the specified id.
*
* @throws PermissionsException
* @throws Exception
*/
public function deleteRole($roleId, $migrateRoleId)
public function deleteRole(int $roleId, int $migrateRoleId = 0): void
{
$role = $this->getRoleById($roleId);
@@ -131,7 +131,7 @@ class PermissionsRepo
throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
}
if ($migrateRoleId) {
if ($migrateRoleId !== 0) {
$newRole = Role::query()->find($migrateRoleId);
if ($newRole) {
$users = $role->users()->pluck('id')->toArray();
@@ -139,6 +139,7 @@ class PermissionsRepo
}
}
$role->entityPermissions()->delete();
$role->jointPermissions()->delete();
Activity::add(ActivityType::ROLE_DELETE, $role);
$role->delete();

View File

@@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
/**
* @property int $id
* @property string $name
* @property string $display_name
*/
class RolePermission extends Model
{

View File

@@ -2,12 +2,27 @@
namespace BookStack\Auth\Permissions;
use BookStack\Entities\Models\Entity;
class SimpleEntityData
{
public int $id;
public string $type;
public bool $restricted;
public int $owned_by;
public ?int $book_id;
public ?int $chapter_id;
public static function fromEntity(Entity $entity): self
{
$attrs = $entity->getAttributes();
$simple = new self();
$simple->id = $attrs['id'];
$simple->type = $entity->getMorphClass();
$simple->owned_by = $attrs['owned_by'] ?? 0;
$simple->book_id = $attrs['book_id'] ?? null;
$simple->chapter_id = $attrs['chapter_id'] ?? null;
return $simple;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace BookStack\Auth\Queries;
use BookStack\Auth\Role;
use BookStack\Util\SimpleListOptions;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Get all the roles in the system in a paginated format.
*/
class RolesAllPaginatedAndSorted
{
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
{
$sort = $listOptions->getSort();
if ($sort === 'created_at') {
$sort = 'users.created_at';
}
$query = Role::query()->select(['*'])
->withCount(['users', 'permissions'])
->orderBy($sort, $listOptions->getOrder());
if ($listOptions->getSearch()) {
$term = '%' . $listOptions->getSearch() . '%';
$query->where(function ($query) use ($term) {
$query->where('display_name', 'like', $term)
->orWhere('description', 'like', $term);
});
}
return $query->paginate($count);
}
}

View File

@@ -3,6 +3,7 @@
namespace BookStack\Auth\Queries;
use BookStack\Auth\User;
use BookStack\Util\SimpleListOptions;
use Illuminate\Pagination\LengthAwarePaginator;
/**
@@ -11,23 +12,23 @@ use Illuminate\Pagination\LengthAwarePaginator;
* user is assumed to be trusted. (Admin users).
* Email search can be abused to extract email addresses.
*/
class AllUsersPaginatedAndSorted
class UsersAllPaginatedAndSorted
{
/**
* @param array{sort: string, order: string, search: string} $sortData
*/
public function run(int $count, array $sortData): LengthAwarePaginator
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
{
$sort = $sortData['sort'];
$sort = $listOptions->getSort();
if ($sort === 'created_at') {
$sort = 'users.created_at';
}
$query = User::query()->select(['*'])
->scopes(['withLastActivityAt'])
->with(['roles', 'avatar'])
->withCount('mfaValues')
->orderBy($sort, $sortData['order']);
->orderBy($sort, $listOptions->getOrder());
if ($sortData['search']) {
$term = '%' . $sortData['search'] . '%';
if ($listOptions->getSearch()) {
$term = '%' . $listOptions->getSearch() . '%';
$query->where(function ($query) use ($term) {
$query->where('name', 'like', $term)
->orWhere('email', 'like', $term);

View File

@@ -2,6 +2,7 @@
namespace BookStack\Auth;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Interfaces\Loggable;
@@ -26,10 +27,14 @@ class Role extends Model implements Loggable
{
use HasFactory;
protected $fillable = ['display_name', 'description', 'external_auth_id'];
protected $fillable = ['display_name', 'description', 'external_auth_id', 'mfa_enforced'];
protected $hidden = ['pivot'];
protected $casts = [
'mfa_enforced' => 'boolean',
];
/**
* The roles that belong to the role.
*/
@@ -54,6 +59,14 @@ class Role extends Model implements Loggable
return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
}
/**
* Get the entity permissions assigned to this role.
*/
public function entityPermissions(): HasMany
{
return $this->hasMany(EntityPermission::class);
}
/**
* Check if this role has a permission.
*/
@@ -98,26 +111,13 @@ class Role extends Model implements Loggable
*/
public static function getSystemRole(string $systemName): ?self
{
return static::query()->where('system_name', '=', $systemName)->first();
}
static $cache = [];
/**
* Get all visible roles.
*/
public static function visible(): Collection
{
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
}
if (!isset($cache[$systemName])) {
$cache[$systemName] = static::query()->where('system_name', '=', $systemName)->first();
}
/**
* Get the roles that can be restricted.
*/
public static function restrictable(): Collection
{
return static::query()
->where('system_name', '!=', 'admin')
->orderBy('display_name', 'asc')
->get();
return $cache[$systemName];
}
/**

View File

@@ -72,7 +72,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected $hidden = [
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
'created_at', 'updated_at', 'image_id', 'roles', 'avatar', 'user_id',
'created_at', 'updated_at', 'image_id', 'roles', 'avatar', 'user_id', 'pivot',
];
/**
@@ -80,6 +80,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected ?Collection $permissions;
/**
* This holds the user's avatar URL when loaded to prevent re-calculating within the same request.
*/
protected string $avatarUrl = '';
/**
* This holds the default user when loaded.
*
@@ -195,6 +200,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
public function attachRole(Role $role)
{
$this->roles()->attach($role->id);
$this->unsetRelation('roles');
}
/**
@@ -233,12 +239,18 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $default;
}
if (!empty($this->avatarUrl)) {
return $this->avatarUrl;
}
try {
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
} catch (Exception $err) {
$avatar = $default;
}
$this->avatarUrl = $avatar;
return $avatar;
}

View File

@@ -10,6 +10,7 @@ use BookStack\Exceptions\UserUpdateException;
use BookStack\Facades\Activity;
use BookStack\Uploads\UserAvatars;
use Exception;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@@ -61,7 +62,7 @@ class UserRepo
$user = new User();
$user->name = $data['name'];
$user->email = $data['email'];
$user->password = bcrypt(empty($data['password']) ? Str::random(32) : $data['password']);
$user->password = Hash::make(empty($data['password']) ? Str::random(32) : $data['password']);
$user->email_confirmed = $emailConfirmed;
$user->external_auth_id = $data['external_auth_id'] ?? '';
@@ -126,7 +127,7 @@ class UserRepo
}
if (!empty($data['password'])) {
$user->password = bcrypt($data['password']);
$user->password = Hash::make($data['password']);
}
if (!empty($data['language'])) {
@@ -157,6 +158,9 @@ class UserRepo
// Delete user profile images
$this->userAvatar->destroyAllForUser($user);
// Delete related activities
setting()->deleteUserSettings($user->id);
if (!empty($newOwnerId)) {
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {
@@ -230,6 +234,8 @@ class UserRepo
*/
protected function setUserRoles(User $user, array $roles)
{
$roles = array_filter(array_values($roles));
if ($this->demotingLastAdmin($user, $roles)) {
throw new UserUpdateException(trans('errors.role_cannot_remove_only_admin'), $user->getEditUrl());
}

View File

@@ -8,6 +8,8 @@
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
use Illuminate\Support\Facades\Facade;
return [
// The environment to run BookStack in.
@@ -22,7 +24,7 @@ return [
// The number of revisions to keep in the database.
// Once this limit is reached older revisions will be deleted.
// If set to false then a limit will not be enforced.
'revision_limit' => env('REVISION_LIMIT', 50),
'revision_limit' => env('REVISION_LIMIT', 100),
// The number of days that content will remain in the recycle bin before
// being considered for auto-removal. It is not a guarantee that content will
@@ -75,7 +77,7 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ka', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
// Application Fallback Locale
'fallback_locale' => 'en',
@@ -98,7 +100,13 @@ return [
// Encryption cipher
'cipher' => 'AES-256-CBC',
// Application Services Provides
// Maintenance Mode Driver
'maintenance' => [
'driver' => 'file',
// 'store' => 'redis',
],
// Application Service Providers
'providers' => [
// Laravel Framework Service Providers...
@@ -114,6 +122,8 @@ return [
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,
@@ -121,81 +131,27 @@ return [
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
SocialiteProviders\Manager\ServiceProvider::class,
// Third party service providers
Intervention\Image\ImageServiceProvider::class,
Barryvdh\DomPDF\ServiceProvider::class,
Barryvdh\Snappy\ServiceProvider::class,
// BookStack replacement service providers (Extends Laravel)
BookStack\Providers\PaginationServiceProvider::class,
BookStack\Providers\TranslationServiceProvider::class,
Intervention\Image\ImageServiceProvider::class,
SocialiteProviders\Manager\ServiceProvider::class,
// BookStack custom service providers
BookStack\Providers\ThemeServiceProvider::class,
BookStack\Providers\AuthServiceProvider::class,
BookStack\Providers\AppServiceProvider::class,
BookStack\Providers\BroadcastServiceProvider::class,
BookStack\Providers\AuthServiceProvider::class,
BookStack\Providers\EventServiceProvider::class,
BookStack\Providers\RouteServiceProvider::class,
BookStack\Providers\CustomFacadeProvider::class,
BookStack\Providers\CustomValidationServiceProvider::class,
BookStack\Providers\TranslationServiceProvider::class,
BookStack\Providers\ValidationRuleServiceProvider::class,
BookStack\Providers\ViewTweaksServiceProvider::class,
],
/*
|--------------------------------------------------------------------------
| Class Aliases
|--------------------------------------------------------------------------
|
| This array of class aliases will be registered when this application
| is started. However, feel free to register as many as you wish as
| the aliases are "lazy" loaded so they don't hinder performance.
|
*/
// Class aliases, Registered on application start
'aliases' => [
// Laravel
'App' => Illuminate\Support\Facades\App::class,
'Arr' => Illuminate\Support\Arr::class,
'Artisan' => Illuminate\Support\Facades\Artisan::class,
'Auth' => Illuminate\Support\Facades\Auth::class,
'Blade' => Illuminate\Support\Facades\Blade::class,
'Bus' => Illuminate\Support\Facades\Bus::class,
'Cache' => Illuminate\Support\Facades\Cache::class,
'Config' => Illuminate\Support\Facades\Config::class,
'Cookie' => Illuminate\Support\Facades\Cookie::class,
'Crypt' => Illuminate\Support\Facades\Crypt::class,
'Date' => Illuminate\Support\Facades\Date::class,
'DB' => Illuminate\Support\Facades\DB::class,
'Eloquent' => Illuminate\Database\Eloquent\Model::class,
'Event' => Illuminate\Support\Facades\Event::class,
'File' => Illuminate\Support\Facades\File::class,
'Gate' => Illuminate\Support\Facades\Gate::class,
'Hash' => Illuminate\Support\Facades\Hash::class,
'Http' => Illuminate\Support\Facades\Http::class,
'Lang' => Illuminate\Support\Facades\Lang::class,
'Log' => Illuminate\Support\Facades\Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class,
'Notification' => Illuminate\Support\Facades\Notification::class,
'Password' => Illuminate\Support\Facades\Password::class,
'Queue' => Illuminate\Support\Facades\Queue::class,
'RateLimiter' => Illuminate\Support\Facades\RateLimiter::class,
'Redirect' => Illuminate\Support\Facades\Redirect::class,
// 'Redis' => Illuminate\Support\Facades\Redis::class,
'Request' => Illuminate\Support\Facades\Request::class,
'Response' => Illuminate\Support\Facades\Response::class,
'Route' => Illuminate\Support\Facades\Route::class,
'Schema' => Illuminate\Support\Facades\Schema::class,
'Session' => Illuminate\Support\Facades\Session::class,
'Storage' => Illuminate\Support\Facades\Storage::class,
'Str' => Illuminate\Support\Str::class,
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
// Class Aliases
// This array of class aliases to be registered on application start.
'aliases' => Facade::defaultAliases()->merge([
// Laravel Packages
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
@@ -205,7 +161,7 @@ return [
// Custom BookStack
'Activity' => BookStack\Facades\Activity::class,
'Theme' => BookStack\Facades\Theme::class,
],
])->toArray(),
// Proxy configuration
'proxies' => env('APP_PROXIES', ''),

View File

@@ -14,7 +14,7 @@ return [
// This option controls the default broadcaster that will be used by the
// framework when an event needs to be broadcast. This can be set to
// any of the connections defined in the "connections" array below.
'default' => env('BROADCAST_DRIVER', 'pusher'),
'default' => 'null',
// Broadcast Connections
// Here you may define all of the broadcast connections that will be used
@@ -22,21 +22,7 @@ return [
// each available type of connection are provided inside this array.
'connections' => [
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'useTLS' => true,
],
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
// Default options removed since we don't use broadcasting.
'log' => [
'driver' => 'log',

View File

@@ -87,6 +87,6 @@ return [
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache'),
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache_'),
];

View File

@@ -7,6 +7,7 @@
* 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.
*/
$dompdfPaperSizeMap = [
'a4' => 'a4',
'letter' => 'letter',

View File

@@ -33,17 +33,20 @@ return [
'driver' => 'local',
'root' => public_path(),
'visibility' => 'public',
'throw' => true,
],
'local_secure_attachments' => [
'driver' => 'local',
'root' => storage_path('uploads/files/'),
'throw' => true,
],
'local_secure_images' => [
'driver' => 'local',
'root' => storage_path('uploads/images/'),
'visibility' => 'public',
'throw' => true,
],
's3' => [
@@ -54,6 +57,7 @@ return [
'bucket' => env('STORAGE_S3_BUCKET', 'your-bucket'),
'endpoint' => env('STORAGE_S3_ENDPOINT', null),
'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null,
'throw' => true,
],
],

View File

@@ -21,6 +21,15 @@ return [
// one of the channels defined in the "channels" configuration array.
'default' => env('LOG_CHANNEL', 'single'),
// Deprecations Log Channel
// This option controls the log channel that should be used to log warnings
// regarding deprecated PHP and library features. This allows you to get
// your application ready for upcoming major versions of dependencies.
'deprecations' => [
'channel' => 'null',
'trace' => false,
],
// Log Channels
// Here you may configure the log channels for your application. Out of
// the box, Laravel uses the Monolog PHP logging library. This gives

View File

@@ -14,13 +14,7 @@ return [
// From Laravel 7+ this is MAIL_MAILER in laravel.
// Kept as MAIL_DRIVER in BookStack to prevent breaking change.
// Options: smtp, sendmail, log, array
'driver' => env('MAIL_DRIVER', 'smtp'),
// SMTP host address
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
// SMTP host port
'port' => env('MAIL_PORT', 587),
'default' => env('MAIL_DRIVER', 'smtp'),
// Global "From" address & name
'from' => [
@@ -28,17 +22,42 @@ return [
'name' => env('MAIL_FROM_NAME', 'BookStack'),
],
// Email encryption protocol
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
// Mailer Configurations
// Available mailing methods and their settings.
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
'port' => env('MAIL_PORT', 587),
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN'),
],
// SMTP server username
'username' => env('MAIL_USERNAME'),
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'),
],
// SMTP server password
'password' => env('MAIL_PASSWORD'),
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
// Sendmail application path
'sendmail' => '/usr/sbin/sendmail -bs',
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
],
],
// Email markdown configuration
'markdown' => [
@@ -47,11 +66,4 @@ return [
resource_path('views/vendor/mail'),
],
],
// Log Channel
// If you are using the "log" driver, you may specify the logging channel
// if you prefer to keep mail messages separate from other log entries
// for simpler reading. Otherwise, the default channel will be used.
'log_channel' => env('MAIL_LOG_CHANNEL'),
];

View File

@@ -8,9 +8,12 @@ return [
// Dump user details after a login request for debugging purposes
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
// Attribute, within a OpenId token, to find the user's display name
// Claim, within an OpenId token, to find the user's display name
'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')),
// Claim, within an OpenID token, to use to connect a BookStack user to the OIDC user.
'external_id_claim' => env('OIDC_EXTERNAL_ID_CLAIM', 'sub'),
// OAuth2/OpenId client id, as configured in your Authorization server.
'client_id' => env('OIDC_CLIENT_ID', null),
@@ -32,4 +35,16 @@ return [
// OAuth2 endpoints.
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
// Add extra scopes, upon those required, to the OIDC authentication request
// Multiple values can be provided comma seperated.
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
// Group sync options
// Enable syncing, upon login, of OIDC groups to BookStack roles
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
// Attribute, within a OIDC ID token, to find group names within
'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'),
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false),
];

View File

@@ -16,16 +16,27 @@ return [
'app-editor' => 'wysiwyg',
'app-color' => '#206ea7',
'app-color-light' => 'rgba(32,110,167,0.15)',
'link-color' => '#206ea7',
'bookshelf-color' => '#a94747',
'book-color' => '#077b70',
'chapter-color' => '#af4d0d',
'page-color' => '#206ea7',
'page-draft-color' => '#7e50b1',
'app-color-dark' => '#195785',
'app-color-light-dark' => 'rgba(32,110,167,0.15)',
'link-color-dark' => '#429fe3',
'bookshelf-color-dark' => '#ff5454',
'book-color-dark' => '#389f60',
'chapter-color-dark' => '#ee7a2d',
'page-color-dark' => '#429fe3',
'page-draft-color-dark' => '#a66ce8',
'app-custom-head' => false,
'registration-enabled' => false,
// User-level default settings
'user' => [
'ui-shortcuts' => '{}',
'ui-shortcuts-enabled' => false,
'dark-mode-enabled' => env('APP_DEFAULT_DARK_MODE', false),
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),

View File

@@ -7,6 +7,7 @@
* 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',

View File

@@ -3,7 +3,7 @@
namespace BookStack\Console\Commands;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Tools\PermissionsUpdater;
use Illuminate\Console\Command;
class CopyShelfPermissions extends Command
@@ -25,19 +25,16 @@ class CopyShelfPermissions extends Command
*/
protected $description = 'Copy shelf permissions to all child books';
/**
* @var BookshelfRepo
*/
protected $bookshelfRepo;
protected PermissionsUpdater $permissionsUpdater;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct(BookshelfRepo $repo)
public function __construct(PermissionsUpdater $permissionsUpdater)
{
$this->bookshelfRepo = $repo;
$this->permissionsUpdater = $permissionsUpdater;
parent::__construct();
}
@@ -69,18 +66,18 @@ class CopyShelfPermissions extends Command
return;
}
$shelves = Bookshelf::query()->get(['id', 'restricted']);
$shelves = Bookshelf::query()->get(['id']);
}
if ($shelfSlug) {
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id', 'restricted']);
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id']);
if ($shelves->count() === 0) {
$this->info('No shelves found with the given slug.');
}
}
foreach ($shelves as $shelf) {
$this->bookshelfRepo->copyDownPermissions($shelf, false);
$this->permissionsUpdater->updateBookPermissionsFromShelf($shelf, false);
$this->info('Copied permissions for shelf [' . $shelf->id . ']');
}

View File

@@ -5,6 +5,7 @@ namespace BookStack\Console\Commands;
use BookStack\Actions\Comment;
use BookStack\Actions\CommentRepo;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RegenerateCommentContent extends Command
{
@@ -43,9 +44,9 @@ class RegenerateCommentContent extends Command
*/
public function handle()
{
$connection = \DB::getDefaultConnection();
$connection = DB::getDefaultConnection();
if ($this->option('database') !== null) {
\DB::setDefaultConnection($this->option('database'));
DB::setDefaultConnection($this->option('database'));
}
Comment::query()->chunk(100, function ($comments) {
@@ -55,7 +56,9 @@ class RegenerateCommentContent extends Command
}
});
\DB::setDefaultConnection($connection);
DB::setDefaultConnection($connection);
$this->comment('Comment HTML content has been regenerated');
return 0;
}
}

View File

@@ -50,5 +50,7 @@ class RegeneratePermissions extends Command
DB::setDefaultConnection($connection);
$this->comment('Permissions regenerated');
return 0;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\References\ReferenceStore;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RegenerateReferences extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:regenerate-references {--database= : The database connection to use.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Regenerate all the cross-item model reference index';
protected ReferenceStore $references;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct(ReferenceStore $references)
{
$this->references = $references;
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$connection = DB::getDefaultConnection();
if ($this->option('database')) {
DB::setDefaultConnection($this->option('database'));
}
$this->references->updateForAllPages();
DB::setDefaultConnection($connection);
$this->comment('References have been regenerated');
return 0;
}
}

View File

@@ -3,7 +3,7 @@
namespace BookStack\Console\Commands;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\SearchIndex;
use BookStack\Search\SearchIndex;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

View File

@@ -19,6 +19,7 @@ use Illuminate\Support\Collection;
* @property \Illuminate\Database\Eloquent\Collection $chapters
* @property \Illuminate\Database\Eloquent\Collection $pages
* @property \Illuminate\Database\Eloquent\Collection $directPages
* @property \Illuminate\Database\Eloquent\Collection $shelves
*/
class Book extends Entity implements HasCoverImage
{
@@ -27,7 +28,7 @@ class Book extends Entity implements HasCoverImage
public $searchFactor = 1.2;
protected $fillable = ['name', 'description'];
protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at'];
protected $hidden = ['pivot', 'image_id', 'deleted_at'];
/**
* Get the url for this book.
@@ -119,4 +120,13 @@ class Book extends Entity implements HasCoverImage
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
}
/**
* Get a visible book by its slug.
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function getBySlug(string $slug): self
{
return static::visible()->where('slug', '=', $slug)->firstOrFail();
}
}

View File

@@ -2,6 +2,7 @@
namespace BookStack\Entities\Models;
use BookStack\References\ReferenceUpdater;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -57,11 +58,16 @@ abstract class BookChild extends Entity
*/
public function changeBook(int $newBookId): Entity
{
$oldUrl = $this->getUrl();
$this->book_id = $newBookId;
$this->refreshSlug();
$this->save();
$this->refresh();
if ($oldUrl !== $this->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityPageReferences($this, $oldUrl);
}
// Update all child pages if a chapter
if ($this instanceof Chapter) {
foreach ($this->pages()->withTrashed()->get() as $page) {

View File

@@ -17,7 +17,7 @@ class Bookshelf extends Entity implements HasCoverImage
protected $fillable = ['name', 'description', 'image_id'];
protected $hidden = ['restricted', 'image_id', 'deleted_at'];
protected $hidden = ['image_id', 'deleted_at'];
/**
* Get the books in this shelf.
@@ -86,7 +86,7 @@ class Bookshelf extends Entity implements HasCoverImage
*/
public function coverImageTypeKey(): string
{
return 'cover_shelf';
return 'cover_bookshelf';
}
/**
@@ -109,4 +109,13 @@ class Bookshelf extends Entity implements HasCoverImage
$maxOrder = $this->books()->max('order');
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
}
/**
* Get a visible shelf by its slug.
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function getBySlug(string $slug): self
{
return static::visible()->where('slug', '=', $slug)->firstOrFail();
}
}

View File

@@ -19,7 +19,7 @@ class Chapter extends BookChild
public $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'priority'];
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
protected $hidden = ['pivot', 'deleted_at'];
/**
* Get the pages that this chapter contains.
@@ -58,4 +58,13 @@ class Chapter extends BookChild
->orderBy('priority', 'asc')
->get();
}
/**
* Get a visible chapter by its book and page slugs.
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function getBySlugs(string $bookSlug, string $chapterSlug): self
{
return static::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
}
}

View File

@@ -11,7 +11,6 @@ use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\JointPermissionBuilder;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Tools\SearchIndex;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Interfaces\Deletable;
use BookStack\Interfaces\Favouritable;
@@ -19,6 +18,9 @@ use BookStack\Interfaces\Loggable;
use BookStack\Interfaces\Sluggable;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
use BookStack\References\Reference;
use BookStack\Search\SearchIndex;
use BookStack\Search\SearchTerm;
use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner;
use Carbon\Carbon;
@@ -40,7 +42,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property Carbon $deleted_at
* @property int $created_by
* @property int $updated_by
* @property bool $restricted
* @property Collection $tags
*
* @method static Entity|Builder visible()
@@ -174,16 +175,15 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function permissions(): MorphMany
{
return $this->morphMany(EntityPermission::class, 'restrictable');
return $this->morphMany(EntityPermission::class, 'entity');
}
/**
* Check if this entity has a specific restriction set against it.
*/
public function hasRestriction(int $role_id, string $action): bool
public function hasPermissions(): bool
{
return $this->permissions()->where('role_id', '=', $role_id)
->where('action', '=', $action)->count() > 0;
return $this->permissions()->count() > 0;
}
/**
@@ -202,6 +202,22 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
return $this->morphMany(Deletion::class, 'deletable');
}
/**
* Get the references pointing from this entity to other items.
*/
public function referencesFrom(): MorphMany
{
return $this->morphMany(Reference::class, 'from');
}
/**
* Get the references pointing to this entity from other items.
*/
public function referencesTo(): MorphMany
{
return $this->morphMany(Reference::class, 'to');
}
/**
* Check if this instance or class is a certain type of entity.
* Examples of $type are 'page', 'book', 'chapter'.

View File

@@ -39,7 +39,7 @@ class Page extends BookChild
public $textField = 'text';
protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot', 'deleted_at'];
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
protected $casts = [
'draft' => 'boolean',
@@ -88,8 +88,6 @@ class Page extends BookChild
/**
* Get the current revision for the page if existing.
*
* @return PageRevision|null
*/
public function currentRevision(): HasOne
{
@@ -145,4 +143,13 @@ class Page extends BookChild
return $refreshed;
}
/**
* Get a visible page by its book and page slugs.
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function getBySlugs(string $bookSlug, string $pageSlug): self
{
return static::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail();
}
}

View File

@@ -3,6 +3,7 @@
namespace BookStack\Entities\Models;
use BookStack\Auth\User;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -27,10 +28,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property Page $page
* @property-read ?User $createdBy
*/
class PageRevision extends Model
class PageRevision extends Model implements Loggable
{
protected $fillable = ['name', 'text', 'summary'];
protected $hidden = ['html', 'markdown', 'restricted', 'text'];
protected $hidden = ['html', 'markdown', 'text'];
/**
* Get the user that created the page revision.
@@ -83,4 +84,9 @@ class PageRevision extends Model
{
return $type === 'revision';
}
public function logDescriptor(): string
{
return "Revision #{$this->revision_number} (ID: {$this->id}) for page ID {$this->page_id}";
}
}

View File

@@ -6,6 +6,7 @@ use BookStack\Actions\TagRepo;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceUpdater;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\UploadedFile;
@@ -13,11 +14,13 @@ class BaseRepo
{
protected TagRepo $tagRepo;
protected ImageRepo $imageRepo;
protected ReferenceUpdater $referenceUpdater;
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo, ReferenceUpdater $referenceUpdater)
{
$this->tagRepo = $tagRepo;
$this->imageRepo = $imageRepo;
$this->referenceUpdater = $referenceUpdater;
}
/**
@@ -38,6 +41,7 @@ class BaseRepo
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
}
$entity->refresh();
$entity->rebuildPermissions();
$entity->indexForSearch();
}
@@ -47,10 +51,12 @@ class BaseRepo
*/
public function update(Entity $entity, array $input)
{
$oldUrl = $entity->getUrl();
$entity->fill($input);
$entity->updated_by = user()->id;
if ($entity->isDirty('name')) {
if ($entity->isDirty('name') || empty($entity->slug)) {
$entity->refreshSlug();
}
@@ -63,6 +69,10 @@ class BaseRepo
$entity->rebuildPermissions();
$entity->indexForSearch();
if ($oldUrl !== $entity->getUrl()) {
$this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);
}
}
/**
@@ -76,14 +86,15 @@ class BaseRepo
public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false)
{
if ($coverImage) {
$this->imageRepo->destroyImage($entity->cover);
$image = $this->imageRepo->saveNew($coverImage, 'cover_book', $entity->id, 512, 512, true);
$imageType = $entity->coverImageTypeKey();
$this->imageRepo->destroyImage($entity->cover()->first());
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
$entity->cover()->associate($image);
$entity->save();
}
if ($removeImage) {
$this->imageRepo->destroyImage($entity->cover);
$this->imageRepo->destroyImage($entity->cover()->first());
$entity->image_id = 0;
$entity->save();
}

View File

@@ -134,31 +134,6 @@ class BookshelfRepo
$shelf->books()->sync($syncData);
}
/**
* Copy down the permissions of the given shelf to all child books.
*/
public function copyDownPermissions(Bookshelf $shelf, $checkUserPermissions = true): int
{
$shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray();
$shelfBooks = $shelf->books()->get(['id', 'restricted']);
$updatedBookCount = 0;
/** @var Book $book */
foreach ($shelfBooks as $book) {
if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
continue;
}
$book->permissions()->delete();
$book->restricted = $shelf->restricted;
$book->permissions()->createMany($shelfPermissions);
$book->save();
$book->rebuildPermissions();
$updatedBookCount++;
}
return $updatedBookCount;
}
/**
* Remove a bookshelf from the system.
*

View File

@@ -16,20 +16,31 @@ use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
class PageRepo
{
protected $baseRepo;
protected BaseRepo $baseRepo;
protected RevisionRepo $revisionRepo;
protected ReferenceStore $referenceStore;
protected ReferenceUpdater $referenceUpdater;
/**
* PageRepo constructor.
*/
public function __construct(BaseRepo $baseRepo)
{
public function __construct(
BaseRepo $baseRepo,
RevisionRepo $revisionRepo,
ReferenceStore $referenceStore,
ReferenceUpdater $referenceUpdater
) {
$this->baseRepo = $baseRepo;
$this->revisionRepo = $revisionRepo;
$this->referenceStore = $referenceStore;
$this->referenceUpdater = $referenceUpdater;
}
/**
@@ -39,6 +50,7 @@ class PageRepo
*/
public function getById(int $id, array $relations = ['book']): Page
{
/** @var Page $page */
$page = Page::visible()->with($relations)->find($id);
if (!$page) {
@@ -70,17 +82,7 @@ class PageRepo
*/
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
{
/** @var ?PageRevision $revision */
$revision = PageRevision::query()
->whereHas('page', function (Builder $query) {
$query->scopes('visible');
})
->where('slug', '=', $pageSlug)
->where('type', '=', 'version')
->where('book_slug', '=', $bookSlug)
->orderBy('created_at', 'desc')
->with('page')
->first();
$revision = $this->revisionRepo->getBySlugs($bookSlug, $pageSlug);
return $revision->page ?? null;
}
@@ -112,7 +114,7 @@ class PageRepo
public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity
{
if ($chapterSlug !== null) {
return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
return Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
}
return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
@@ -123,9 +125,7 @@ class PageRepo
*/
public function getUserDraft(Page $page): ?PageRevision
{
$revision = $this->getUserDraftQuery($page)->first();
return $revision;
return $this->revisionRepo->getLatestDraftForCurrentUser($page);
}
/**
@@ -165,11 +165,10 @@ class PageRepo
$draft->draft = false;
$draft->revision_count = 1;
$draft->priority = $this->getNewPriority($draft);
$draft->refreshSlug();
$draft->save();
$this->savePageRevision($draft, trans('entities.pages_initial_revision'));
$draft->indexForSearch();
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
$this->referenceStore->updateForPage($draft);
$draft->refresh();
Activity::add(ActivityType::PAGE_CREATE, $draft);
@@ -189,13 +188,14 @@ class PageRepo
$this->updateTemplateStatusAndContentFromInput($page, $input);
$this->baseRepo->update($page, $input);
$this->referenceStore->updateForPage($page);
// Update with new details
$page->revision_count++;
$page->save();
// Remove all update drafts for this user & page.
$this->getUserDraftQuery($page)->delete();
$this->revisionRepo->deleteDraftsForCurrentUser($page);
// Save a revision after updating
$summary = trim($input['summary'] ?? '');
@@ -203,7 +203,7 @@ class PageRepo
$nameChanged = isset($input['name']) && $input['name'] !== $oldName;
$markdownChanged = isset($input['markdown']) && $input['markdown'] !== $oldMarkdown;
if ($htmlChanged || $nameChanged || $markdownChanged || $summary) {
$this->savePageRevision($page, $summary);
$this->revisionRepo->storeNewForPage($page, $summary);
}
Activity::add(ActivityType::PAGE_UPDATE, $page);
@@ -239,32 +239,6 @@ class PageRepo
}
}
/**
* Saves a page revision into the system.
*/
protected function savePageRevision(Page $page, string $summary = null): PageRevision
{
$revision = new PageRevision();
$revision->name = $page->name;
$revision->html = $page->html;
$revision->markdown = $page->markdown;
$revision->text = $page->text;
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
$revision->created_by = user()->id;
$revision->created_at = $page->updated_at;
$revision->type = 'version';
$revision->summary = $summary;
$revision->revision_number = $page->revision_count;
$revision->save();
$this->deleteOldRevisions($page);
return $revision;
}
/**
* Save a page update draft.
*/
@@ -280,7 +254,7 @@ class PageRepo
}
// Otherwise, save the data to a revision
$draft = $this->getPageRevisionToUpdate($page);
$draft = $this->revisionRepo->getNewDraftForCurrentUser($page);
$draft->fill($input);
if (!empty($input['markdown'])) {
@@ -314,6 +288,7 @@ class PageRepo
*/
public function restoreRevision(Page $page, int $revisionId): Page
{
$oldUrl = $page->getUrl();
$page->revision_count++;
/** @var PageRevision $revision */
@@ -332,11 +307,17 @@ class PageRepo
$page->refreshSlug();
$page->save();
$page->indexForSearch();
$this->referenceStore->updateForPage($page);
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
$this->savePageRevision($page, $summary);
$this->revisionRepo->storeNewForPage($page, $summary);
if ($oldUrl !== $page->getUrl()) {
$this->referenceUpdater->updateEntityPageReferences($page, $oldUrl);
}
Activity::add(ActivityType::PAGE_RESTORE, $page);
Activity::add(ActivityType::REVISION_RESTORE, $revision);
return $page;
}
@@ -392,48 +373,6 @@ class PageRepo
return $parentClass::visible()->where('id', '=', $entityId)->first();
}
/**
* Get a page revision to update for the given page.
* Checks for an existing revisions before providing a fresh one.
*/
protected function getPageRevisionToUpdate(Page $page): PageRevision
{
$drafts = $this->getUserDraftQuery($page)->get();
if ($drafts->count() > 0) {
return $drafts->first();
}
$draft = new PageRevision();
$draft->page_id = $page->id;
$draft->slug = $page->slug;
$draft->book_slug = $page->book->slug;
$draft->created_by = user()->id;
$draft->type = 'update_draft';
return $draft;
}
/**
* Delete old revisions, for the given page, from the system.
*/
protected function deleteOldRevisions(Page $page)
{
$revisionLimit = config('app.revision_limit');
if ($revisionLimit === false) {
return;
}
$revisionsToDelete = PageRevision::query()
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc')
->skip(intval($revisionLimit))
->take(10)
->get(['id']);
if ($revisionsToDelete->count() > 0) {
PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
}
}
/**
* Get a new priority for a page.
*/
@@ -449,15 +388,4 @@ class PageRepo
return (new BookContents($page->book))->getLastPriority() + 1;
}
/**
* Get the query to find the user's draft copies of the given page.
*/
protected function getUserDraftQuery(Page $page)
{
return PageRevision::query()->where('created_by', '=', user()->id)
->where('type', 'update_draft')
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc');
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace BookStack\Entities\Repos;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
use Illuminate\Database\Eloquent\Builder;
class RevisionRepo
{
/**
* Get a revision by its stored book and page slug values.
*/
public function getBySlugs(string $bookSlug, string $pageSlug): ?PageRevision
{
/** @var ?PageRevision $revision */
$revision = PageRevision::query()
->whereHas('page', function (Builder $query) {
$query->scopes('visible');
})
->where('slug', '=', $pageSlug)
->where('type', '=', 'version')
->where('book_slug', '=', $bookSlug)
->orderBy('created_at', 'desc')
->with('page')
->first();
return $revision;
}
/**
* Get the latest draft revision, for the given page, belonging to the current user.
*/
public function getLatestDraftForCurrentUser(Page $page): ?PageRevision
{
/** @var ?PageRevision $revision */
$revision = $this->queryForCurrentUserDraft($page->id)->first();
return $revision;
}
/**
* Delete all drafts revisions, for the given page, belonging to the current user.
*/
public function deleteDraftsForCurrentUser(Page $page): void
{
$this->queryForCurrentUserDraft($page->id)->delete();
}
/**
* Get a user update_draft page revision to update for the given page.
* Checks for an existing revisions before providing a fresh one.
*/
public function getNewDraftForCurrentUser(Page $page): PageRevision
{
$draft = $this->getLatestDraftForCurrentUser($page);
if ($draft) {
return $draft;
}
$draft = new PageRevision();
$draft->page_id = $page->id;
$draft->slug = $page->slug;
$draft->book_slug = $page->book->slug;
$draft->created_by = user()->id;
$draft->type = 'update_draft';
return $draft;
}
/**
* Store a new revision in the system for the given page.
*/
public function storeNewForPage(Page $page, string $summary = null): PageRevision
{
$revision = new PageRevision();
$revision->name = $page->name;
$revision->html = $page->html;
$revision->markdown = $page->markdown;
$revision->text = $page->text;
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
$revision->created_by = user()->id;
$revision->created_at = $page->updated_at;
$revision->type = 'version';
$revision->summary = $summary;
$revision->revision_number = $page->revision_count;
$revision->save();
$this->deleteOldRevisions($page);
return $revision;
}
/**
* Delete old revisions, for the given page, from the system.
*/
protected function deleteOldRevisions(Page $page)
{
$revisionLimit = config('app.revision_limit');
if ($revisionLimit === false) {
return;
}
$revisionsToDelete = PageRevision::query()
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc')
->skip(intval($revisionLimit))
->take(10)
->get(['id']);
if ($revisionsToDelete->count() > 0) {
PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
}
}
/**
* Query update draft revisions for the current user.
*/
protected function queryForCurrentUserDraft(int $pageId): Builder
{
return PageRevision::query()
->where('created_by', '=', user()->id)
->where('type', 'update_draft')
->where('page_id', '=', $pageId)
->orderBy('created_at', 'desc');
}
}

View File

@@ -11,22 +11,15 @@ use Illuminate\Support\Collection;
class BookContents
{
/**
* @var Book
*/
protected $book;
protected Book $book;
/**
* BookContents constructor.
*/
public function __construct(Book $book)
{
$this->book = $book;
}
/**
* Get the current priority of the last item
* at the top-level of the book.
* Get the current priority of the last item at the top-level of the book.
*/
public function getLastPriority(): int
{
@@ -188,7 +181,7 @@ class BookContents
$model->changeBook($newBook->id);
}
if ($chapterChanged) {
if ($model instanceof Page && $chapterChanged) {
$model->chapter_id = $newChapter->id ?? 0;
}
@@ -242,7 +235,7 @@ class BookContents
}
$hasPageEditPermission = userCan('page-update', $model);
$newParentInRightLocation = ($newParent instanceof Book || $newParent->book_id === $newBook->id);
$newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasNewParentPermission = userCan($newParentPermission, $newParent);

View File

@@ -4,8 +4,10 @@ namespace BookStack\Entities\Tools;
use BookStack\Actions\Tag;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\ChapterRepo;
@@ -71,8 +73,10 @@ class Cloner
$bookDetails = $this->entityToInputData($original);
$bookDetails['name'] = $newName;
// Clone book
$copyBook = $this->bookRepo->create($bookDetails);
// Clone contents
$directChildren = $original->getDirectChildren();
foreach ($directChildren as $child) {
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
@@ -84,6 +88,14 @@ class Cloner
}
}
// Clone bookshelf relationships
/** @var Bookshelf $shelf */
foreach ($original->shelves as $shelf) {
if (userCan('bookshelf-update', $shelf)) {
$shelf->appendBook($copyBook);
}
}
return $copyBook;
}
@@ -98,9 +110,11 @@ class Cloner
$inputData['tags'] = $this->entityTagsToInputArray($entity);
// Add a cover to the data if existing on the original entity
if ($entity->cover instanceof Image) {
$uploadedFile = $this->imageToUploadedFile($entity->cover);
$inputData['image'] = $uploadedFile;
if ($entity instanceof HasCoverImage) {
$cover = $entity->cover()->first();
if ($cover) {
$inputData['image'] = $this->imageToUploadedFile($cover);
}
}
return $inputData;
@@ -111,8 +125,7 @@ class Cloner
*/
public function copyEntityPermissions(Entity $sourceEntity, Entity $targetEntity): void
{
$targetEntity->restricted = $sourceEntity->restricted;
$permissions = $sourceEntity->permissions()->get(['role_id', 'action'])->toArray();
$permissions = $sourceEntity->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray();
$targetEntity->permissions()->delete();
$targetEntity->permissions()->createMany($permissions);
$targetEntity->rebuildPermissions();

View File

@@ -235,7 +235,7 @@ class ExportFormatter
$linksOutput = [];
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
// Replace image src with base64 encoded image strings
// Update relative links to be absolute, with instance url
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
foreach ($linksOutput[0] as $index => $linkMatch) {
$oldLinkString = $linkMatch;
@@ -248,7 +248,6 @@ class ExportFormatter
}
}
// Replace any relative links with system domain
return $htmlContent;
}

View File

@@ -65,7 +65,7 @@ class HierarchyTransformer
foreach ($book->chapters as $index => $chapter) {
$newBook = $this->transformChapterToBook($chapter);
$shelfBookSyncData[$newBook->id] = ['order' => $index];
if (!$newBook->restricted) {
if (!$newBook->hasPermissions()) {
$this->cloner->copyEntityPermissions($shelf, $newBook);
}
}

View File

@@ -2,18 +2,18 @@
namespace BookStack\Entities\Tools\Markdown;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\Block\Element\Paragraph;
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\Block\Renderer\ListItemRenderer;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
use League\CommonMark\Extension\CommonMark\Renderer\Block\ListItemRenderer;
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
use League\CommonMark\HtmlElement;
use League\CommonMark\Node\Block\Paragraph;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement;
class CustomListItemRenderer implements BlockRendererInterface
class CustomListItemRenderer implements NodeRendererInterface
{
protected $baseRenderer;
protected ListItemRenderer $baseRenderer;
public function __construct()
{
@@ -23,11 +23,11 @@ class CustomListItemRenderer implements BlockRendererInterface
/**
* @return HtmlElement|string|null
*/
public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{
$listItem = $this->baseRenderer->render($block, $htmlRenderer, $inTightList);
$listItem = $this->baseRenderer->render($node, $childRenderer);
if ($this->startsTaskListItem($block)) {
if ($node instanceof ListItem && $this->startsTaskListItem($node) && $listItem instanceof HtmlElement) {
$listItem->setAttribute('class', 'task-list-item');
}

View File

@@ -2,16 +2,16 @@
namespace BookStack\Entities\Tools\Markdown;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Extension\Strikethrough\Strikethrough;
use League\CommonMark\Extension\Strikethrough\StrikethroughDelimiterProcessor;
class CustomStrikeThroughExtension implements ExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addDelimiterProcessor(new StrikethroughDelimiterProcessor());
$environment->addInlineRenderer(Strikethrough::class, new CustomStrikethroughRenderer());
$environment->addRenderer(Strikethrough::class, new CustomStrikethroughRenderer());
}
}

View File

@@ -2,25 +2,23 @@
namespace BookStack\Entities\Tools\Markdown;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Extension\Strikethrough\Strikethrough;
use League\CommonMark\HtmlElement;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Inline\Renderer\InlineRendererInterface;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement;
/**
* This is a somewhat clone of the League\CommonMark\Extension\Strikethrough\StrikethroughRender
* class but modified slightly to use <s> HTML tags instead of <del> in order to
* match front-end markdown-it rendering.
*/
class CustomStrikethroughRenderer implements InlineRendererInterface
class CustomStrikethroughRenderer implements NodeRendererInterface
{
public function render(AbstractInline $inline, ElementRendererInterface $htmlRenderer)
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{
if (!($inline instanceof Strikethrough)) {
throw new \InvalidArgumentException('Incompatible inline type: ' . get_class($inline));
}
Strikethrough::assertInstanceOf($node);
return new HtmlElement('s', $inline->getData('attributes', []), $htmlRenderer->renderInlines($inline->children()));
return new HtmlElement('s', $node->data->get('attributes'), $childRenderer->renderNodes($node->children()));
}
}

View File

@@ -4,11 +4,12 @@ namespace BookStack\Entities\Tools\Markdown;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter;
class MarkdownToHtml
{
@@ -21,15 +22,16 @@ class MarkdownToHtml
public function convert(): string
{
$environment = Environment::createCommonMarkEnvironment();
$environment = new Environment();
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new TableExtension());
$environment->addExtension(new TaskListExtension());
$environment->addExtension(new CustomStrikeThroughExtension());
$environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
$converter = new CommonMarkConverter([], $environment);
$converter = new MarkdownConverter($environment);
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
$environment->addRenderer(ListItem::class, new CustomListItemRenderer(), 10);
return $converter->convertToHtml($this->markdown);
return $converter->convert($this->markdown)->getContent();
}
}

View File

@@ -5,6 +5,8 @@ namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService;
use BookStack\Util\HtmlContentFilter;
@@ -17,20 +19,15 @@ use Illuminate\Support\Str;
class PageContent
{
protected Page $page;
/**
* PageContent constructor.
*/
public function __construct(Page $page)
{
$this->page = $page;
public function __construct(
protected Page $page
) {
}
/**
* Update the content of the page with new provided HTML.
*/
public function setNewHTML(string $html)
public function setNewHTML(string $html): void
{
$html = $this->extractBase64ImagesFromHtml($html);
$this->page->html = $this->formatHtml($html);
@@ -41,7 +38,7 @@ class PageContent
/**
* Update the content of the page with new provided Markdown content.
*/
public function setNewMarkdown(string $markdown)
public function setNewMarkdown(string $markdown): void
{
$markdown = $this->extractBase64ImagesFromMarkdown($markdown);
$this->page->markdown = $markdown;
@@ -55,7 +52,7 @@ class PageContent
*/
protected function extractBase64ImagesFromHtml(string $htmlText): string
{
if (empty($htmlText) || strpos($htmlText, 'data:image') === false) {
if (empty($htmlText) || !str_contains($htmlText, 'data:image')) {
return $htmlText;
}
@@ -89,7 +86,7 @@ class PageContent
* Attempting to capture the whole data uri using regex can cause PHP
* PCRE limits to be hit with larger, multi-MB, files.
*/
protected function extractBase64ImagesFromMarkdown(string $markdown)
protected function extractBase64ImagesFromMarkdown(string $markdown): string
{
$matches = [];
$contentLength = strlen($markdown);
@@ -181,32 +178,13 @@ class PageContent
$childNodes = $body->childNodes;
$xPath = new DOMXPath($doc);
// Set ids on top-level nodes
// Map to hold used ID references
$idMap = [];
foreach ($childNodes as $index => $childNode) {
[$oldId, $newId] = $this->setUniqueId($childNode, $idMap);
if ($newId && $newId !== $oldId) {
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
}
}
// Map to hold changing ID references
$changeMap = [];
// Set ids on nested header nodes
$nestedHeaders = $xPath->query('//body//*//h1|//body//*//h2|//body//*//h3|//body//*//h4|//body//*//h5|//body//*//h6');
foreach ($nestedHeaders as $nestedHeader) {
[$oldId, $newId] = $this->setUniqueId($nestedHeader, $idMap);
if ($newId && $newId !== $oldId) {
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
}
}
// Ensure no duplicate ids within child items
$idElems = $xPath->query('//body//*//*[@id]');
foreach ($idElems as $domElem) {
[$oldId, $newId] = $this->setUniqueId($domElem, $idMap);
if ($newId && $newId !== $oldId) {
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
}
}
$this->updateIdsRecursively($body, 0, $idMap, $changeMap);
$this->updateLinks($xPath, $changeMap);
// Generate inner html as a string
$html = '';
@@ -221,20 +199,53 @@ class PageContent
}
/**
* Update the all links to the $old location to instead point to $new.
* For the given DOMNode, traverse its children recursively and update IDs
* where required (Top-level, headers & elements with IDs).
* Will update the provided $changeMap array with changes made, where keys are the old
* ids and the corresponding values are the new ids.
*/
protected function updateLinks(DOMXPath $xpath, string $old, string $new)
protected function updateIdsRecursively(DOMNode $element, int $depth, array &$idMap, array &$changeMap): void
{
$old = str_replace('"', '', $old);
$matchingLinks = $xpath->query('//body//*//*[@href="' . $old . '"]');
foreach ($matchingLinks as $domElem) {
$domElem->setAttribute('href', $new);
/* @var DOMNode $child */
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement && ($depth === 0 || in_array($child->nodeName, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) || $child->getAttribute('id'))) {
[$oldId, $newId] = $this->setUniqueId($child, $idMap);
if ($newId && $newId !== $oldId && !isset($idMap[$oldId])) {
$changeMap[$oldId] = $newId;
}
}
if ($child->hasChildNodes()) {
$this->updateIdsRecursively($child, $depth + 1, $idMap, $changeMap);
}
}
}
/**
* Update the all links in the given xpath to apply requires changes within the
* given $changeMap array.
*/
protected function updateLinks(DOMXPath $xpath, array $changeMap): void
{
if (empty($changeMap)) {
return;
}
$links = $xpath->query('//body//*//*[@href]');
/** @var DOMElement $domElem */
foreach ($links as $domElem) {
$href = ltrim($domElem->getAttribute('href'), '#');
$newHref = $changeMap[$href] ?? null;
if ($newHref) {
$domElem->setAttribute('href', '#' . $newHref);
}
}
}
/**
* Set a unique id on the given DOMElement.
* A map for existing ID's should be passed in to check for current existence.
* A map for existing ID's should be passed in to check for current existence,
* and this will be updated with any new IDs set upon elements.
* Returns a pair of strings in the format [old_id, new_id].
*/
protected function setUniqueId(DOMNode $element, array &$idMap): array
@@ -245,7 +256,7 @@ class PageContent
// Stop if there's an existing valid id that has not already been used.
$existingId = $element->getAttribute('id');
if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
if (str_starts_with($existingId, 'bkmrk') && !isset($idMap[$existingId])) {
$idMap[$existingId] = true;
return [$existingId, $existingId];
@@ -256,7 +267,7 @@ class PageContent
// the same content is passed through.
$contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
$newId = urlencode($contentId);
$loopIndex = 0;
$loopIndex = 1;
while (isset($idMap[$newId])) {
$newId = urlencode($contentId . '-' . $loopIndex);
@@ -372,23 +383,30 @@ class PageContent
continue;
}
// Find page and skip this if page not found
// Find page to use, and default replacement to empty string for non-matches.
/** @var ?Page $matchedPage */
$matchedPage = Page::visible()->find($pageId);
if ($matchedPage === null) {
$html = str_replace($fullMatch, '', $html);
continue;
$replacement = '';
if ($matchedPage && count($splitInclude) === 1) {
// If we only have page id, just insert all page html and continue.
$replacement = $matchedPage->html;
} elseif ($matchedPage && count($splitInclude) > 1) {
// Otherwise, if our include tag defines a section, load that specific content
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
$replacement = trim($innerContent);
}
// If we only have page id, just insert all page html and continue.
if (count($splitInclude) === 1) {
$html = str_replace($fullMatch, $matchedPage->html, $html);
continue;
}
$themeReplacement = Theme::dispatch(
ThemeEvents::PAGE_INCLUDE_PARSE,
$includeId,
$replacement,
clone $this->page,
$matchedPage ? (clone $matchedPage) : null,
);
// Create and load HTML into a document
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
$html = str_replace($fullMatch, trim($innerContent), $html);
// Perform the content replacement
$html = str_replace($fullMatch, $themeReplacement ?? $replacement, $html);
}
return $html;
@@ -431,8 +449,8 @@ class PageContent
{
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$html = '<body>' . $html . '</body>';
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
$doc->loadHTML($html);
return $doc;
}

View File

@@ -42,7 +42,7 @@ class PageEditActivity
$userMessage = trans('entities.pages_draft_edit_active.start_b', ['userName' => $firstDraft->createdBy->name ?? '']);
}
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]);
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount' => 60]);
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
}

View File

@@ -3,7 +3,10 @@
namespace BookStack\Entities\Tools;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Activity;
use Illuminate\Http\Request;
@@ -16,11 +19,9 @@ class PermissionsUpdater
*/
public function updateFromPermissionsForm(Entity $entity, Request $request)
{
$restricted = $request->get('restricted') === 'true';
$permissions = $request->get('restrictions', null);
$permissions = $request->get('permissions', null);
$ownerId = $request->get('owned_by', null);
$entity->restricted = $restricted;
$entity->permissions()->delete();
if (!is_null($permissions)) {
@@ -52,18 +53,43 @@ class PermissionsUpdater
}
/**
* Format permissions provided from a permission form to be
* EntityPermission data.
* Format permissions provided from a permission form to be EntityPermission data.
*/
protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): Collection
protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): array
{
return collect($permissions)->flatMap(function ($restrictions, $roleId) {
return collect($restrictions)->keys()->map(function ($action) use ($roleId) {
return [
'role_id' => $roleId,
'action' => strtolower($action),
];
});
});
$formatted = [];
foreach ($permissions as $roleId => $info) {
$entityPermissionData = ['role_id' => $roleId];
foreach (EntityPermission::PERMISSIONS as $permission) {
$entityPermissionData[$permission] = (($info[$permission] ?? false) === "true");
}
$formatted[] = $entityPermissionData;
}
return $formatted;
}
/**
* Copy down the permissions of the given shelf to all child books.
*/
public function updateBookPermissionsFromShelf(Bookshelf $shelf, $checkUserPermissions = true): int
{
$shelfPermissions = $shelf->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray();
$shelfBooks = $shelf->books()->get(['id', 'owned_by']);
$updatedBookCount = 0;
/** @var Book $book */
foreach ($shelfBooks as $book) {
if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
continue;
}
$book->permissions()->delete();
$book->permissions()->createMany($shelfPermissions);
$book->rebuildPermissions();
$updatedBookCount++;
}
return $updatedBookCount;
}
}

View File

@@ -376,6 +376,8 @@ class TrashCan
$entity->searchTerms()->delete();
$entity->deletions()->delete();
$entity->favourites()->delete();
$entity->referencesTo()->delete();
$entity->referencesFrom()->delete();
if ($entity instanceof HasCoverImage && $entity->cover()->exists()) {
$imageService = app()->make(ImageService::class);

View File

@@ -2,25 +2,18 @@
namespace BookStack\Exceptions;
use Whoops\Handler\Handler;
use Illuminate\Contracts\Foundation\ExceptionRenderer;
class WhoopsBookStackPrettyHandler extends Handler
class BookStackExceptionHandlerPage implements ExceptionRenderer
{
/**
* @return int|null A handler may return nothing, or a Handler::HANDLE_* constant
*/
public function handle()
public function render($throwable)
{
$exception = $this->getException();
echo view('errors.debug', [
'error' => $exception->getMessage(),
'errorClass' => get_class($exception),
'trace' => $exception->getTraceAsString(),
return view('errors.debug', [
'error' => $throwable->getMessage(),
'errorClass' => get_class($throwable),
'trace' => $throwable->getTraceAsString(),
'environment' => $this->getEnvironment(),
])->render();
return Handler::QUIT;
}
protected function safeReturn(callable $callback, $default = null)

View File

@@ -17,7 +17,7 @@ class Handler extends ExceptionHandler
/**
* A list of the exception types that are not reported.
*
* @var array
* @var array<int, class-string<\Throwable>>
*/
protected $dontReport = [
NotFoundException::class,
@@ -25,9 +25,9 @@ class Handler extends ExceptionHandler
];
/**
* A list of the inputs that are never flashed for validation exceptions.
* A list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
@@ -98,6 +98,7 @@ class Handler extends ExceptionHandler
];
if ($e instanceof ValidationException) {
$responseData['error']['message'] = 'The given data was invalid.';
$responseData['error']['validation'] = $e->errors();
$code = $e->status;
}

View File

@@ -5,7 +5,7 @@ namespace BookStack\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @see \BookStack\Actions\ActivityLogger
* @mixin \BookStack\Actions\ActivityLogger
*/
class Activity extends Facade
{

View File

@@ -32,10 +32,15 @@ abstract class ApiController extends Controller
*/
public function getValidationRules(): array
{
if (method_exists($this, 'rules')) {
return $this->rules();
}
return $this->rules();
}
/**
* Get the validation rules for the actions in this controller.
* Defaults to a $rules property but can be a rules() method.
*/
protected function rules(): array
{
return $this->rules;
}
}

View File

@@ -13,11 +13,9 @@ use Illuminate\Validation\ValidationException;
class AttachmentApiController extends ApiController
{
protected $attachmentService;
public function __construct(AttachmentService $attachmentService)
{
$this->attachmentService = $attachmentService;
public function __construct(
protected AttachmentService $attachmentService
) {
}
/**
@@ -174,13 +172,13 @@ class AttachmentApiController extends ApiController
'name' => ['required', 'min:1', 'max:255', 'string'],
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
'link' => ['required_without:file', 'min:1', 'max:255', 'safe_url'],
'link' => ['required_without:file', 'min:1', 'max:2000', 'safe_url'],
],
'update' => [
'name' => ['min:1', 'max:255', 'string'],
'uploaded_to' => ['integer', 'exists:pages,id'],
'file' => $this->attachmentService->getFileValidationRules(),
'link' => ['min:1', 'max:255', 'safe_url'],
'link' => ['min:1', 'max:2000', 'safe_url'],
],
];
}

View File

@@ -2,14 +2,18 @@
namespace BookStack\Http\Controllers\Api;
use BookStack\Api\ApiEntityListFormatter;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class BookApiController extends ApiController
{
protected $bookRepo;
protected BookRepo $bookRepo;
public function __construct(BookRepo $bookRepo)
{
@@ -47,11 +51,25 @@ class BookApiController extends ApiController
/**
* View the details of a single book.
* The response data will contain 'content' property listing the chapter and pages directly within, in
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level
* contents will have a 'type' property to distinguish between pages & chapters.
*/
public function read(string $id)
{
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
$contents = (new BookContents($book))->getTree(true, false)->all();
$contentsApiData = (new ApiEntityListFormatter($contents))
->withType()
->withField('pages', function (Entity $entity) {
if ($entity instanceof Chapter) {
return (new ApiEntityListFormatter($entity->pages->all()))->format();
}
return null;
})->format();
$book->setAttribute('contents', $contentsApiData);
return response()->json($book);
}

View File

@@ -13,9 +13,6 @@ class BookshelfApiController extends ApiController
{
protected BookshelfRepo $bookshelfRepo;
/**
* BookshelfApiController constructor.
*/
public function __construct(BookshelfRepo $bookshelfRepo)
{
$this->bookshelfRepo = $bookshelfRepo;

View File

@@ -86,6 +86,9 @@ class PageApiController extends ApiController
*
* Pages will always have HTML content. They may have markdown content
* if the markdown editor was used to last update the page.
*
* See the "Content Security" section of these docs for security considerations when using
* the page content returned from this endpoint.
*/
public function read(string $id)
{

View File

@@ -0,0 +1,136 @@
<?php
namespace BookStack\Http\Controllers\Api;
use BookStack\Auth\Permissions\PermissionsRepo;
use BookStack\Auth\Role;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class RoleApiController extends ApiController
{
protected PermissionsRepo $permissionsRepo;
protected array $fieldsToExpose = [
'display_name', 'description', 'mfa_enforced', 'external_auth_id', 'created_at', 'updated_at',
];
protected $rules = [
'create' => [
'display_name' => ['required', 'string', 'min:3', 'max:180'],
'description' => ['string', 'max:180'],
'mfa_enforced' => ['boolean'],
'external_auth_id' => ['string'],
'permissions' => ['array'],
'permissions.*' => ['string'],
],
'update' => [
'display_name' => ['string', 'min:3', 'max:180'],
'description' => ['string', 'max:180'],
'mfa_enforced' => ['boolean'],
'external_auth_id' => ['string'],
'permissions' => ['array'],
'permissions.*' => ['string'],
]
];
public function __construct(PermissionsRepo $permissionsRepo)
{
$this->permissionsRepo = $permissionsRepo;
// Checks for all endpoints in this controller
$this->middleware(function ($request, $next) {
$this->checkPermission('user-roles-manage');
return $next($request);
});
}
/**
* Get a listing of roles in the system.
* Requires permission to manage roles.
*/
public function list()
{
$roles = Role::query()->select(['*'])
->withCount(['users', 'permissions']);
return $this->apiListingResponse($roles, [
...$this->fieldsToExpose,
'permissions_count',
'users_count',
]);
}
/**
* Create a new role in the system.
* Permissions should be provided as an array of permission name strings.
* Requires permission to manage roles.
*/
public function create(Request $request)
{
$data = $this->validate($request, $this->rules()['create']);
$role = null;
DB::transaction(function () use ($data, &$role) {
$role = $this->permissionsRepo->saveNewRole($data);
});
$this->singleFormatter($role);
return response()->json($role);
}
/**
* View the details of a single role.
* Provides the permissions and a high-level list of the users assigned.
* Requires permission to manage roles.
*/
public function read(string $id)
{
$user = $this->permissionsRepo->getRoleById($id);
$this->singleFormatter($user);
return response()->json($user);
}
/**
* Update an existing role in the system.
* Permissions should be provided as an array of permission name strings.
* An empty "permissions" array would clear granted permissions.
* In many cases, where permissions are changed, you'll want to fetch the existing
* permissions and then modify before providing in your update request.
* Requires permission to manage roles.
*/
public function update(Request $request, string $id)
{
$data = $this->validate($request, $this->rules()['update']);
$role = $this->permissionsRepo->updateRole($id, $data);
$this->singleFormatter($role);
return response()->json($role);
}
/**
* Delete a role from the system.
* Requires permission to manage roles.
*/
public function delete(string $id)
{
$this->permissionsRepo->deleteRole(intval($id));
return response('', 204);
}
/**
* Format the given role model for single-result display.
*/
protected function singleFormatter(Role $role)
{
$role->load('users:id,name,slug');
$role->unsetRelation('permissions');
$role->setAttribute('permissions', $role->permissions()->orderBy('name', 'asc')->pluck('name'));
$role->makeVisible(['users', 'permissions']);
}
}

View File

@@ -2,16 +2,17 @@
namespace BookStack\Http\Controllers\Api;
use BookStack\Api\ApiEntityListFormatter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\SearchOptions;
use BookStack\Entities\Tools\SearchResultsFormatter;
use BookStack\Entities\Tools\SearchRunner;
use BookStack\Search\SearchOptions;
use BookStack\Search\SearchResultsFormatter;
use BookStack\Search\SearchRunner;
use Illuminate\Http\Request;
class SearchApiController extends ApiController
{
protected $searchRunner;
protected $resultsFormatter;
protected SearchRunner $searchRunner;
protected SearchResultsFormatter $resultsFormatter;
protected $rules = [
'all' => [
@@ -50,24 +51,17 @@ class SearchApiController extends ApiController
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
$this->resultsFormatter->format($results['results']->all(), $options);
/** @var Entity $result */
foreach ($results['results'] as $result) {
$result->setVisible([
'id', 'name', 'slug', 'book_id',
'chapter_id', 'draft', 'template',
'created_at', 'updated_at',
'tags', 'type', 'preview_html', 'url',
]);
$result->setAttribute('type', $result->getType());
$result->setAttribute('url', $result->getUrl());
$result->setAttribute('preview_html', [
'name' => (string) $result->getAttribute('preview_name'),
'content' => (string) $result->getAttribute('preview_content'),
]);
}
$data = (new ApiEntityListFormatter($results['results']->all()))
->withType()->withTags()
->withField('preview_html', function (Entity $entity) {
return [
'name' => (string) $entity->getAttribute('preview_name'),
'content' => (string) $entity->getAttribute('preview_content'),
];
})->format();
return response()->json([
'data' => $results['results'],
'data' => $data,
'total' => $results['total'],
]);
}

View File

@@ -13,9 +13,9 @@ use Illuminate\Validation\Rules\Unique;
class UserApiController extends ApiController
{
protected $userRepo;
protected UserRepo $userRepo;
protected $fieldsToExpose = [
protected array $fieldsToExpose = [
'email', 'created_at', 'updated_at', 'last_activity_at', 'external_auth_id',
];
@@ -36,26 +36,26 @@ class UserApiController extends ApiController
{
return [
'create' => [
'name' => ['required', 'min:2'],
'name' => ['required', 'min:2', 'max:100'],
'email' => [
'required', 'min:2', 'email', new Unique('users', 'email'),
],
'external_auth_id' => ['string'],
'language' => ['string'],
'language' => ['string', 'max:15', 'alpha_dash'],
'password' => [Password::default()],
'roles' => ['array'],
'roles.*' => ['integer'],
'send_invite' => ['boolean'],
],
'update' => [
'name' => ['min:2'],
'name' => ['min:2', 'max:100'],
'email' => [
'min:2',
'email',
(new Unique('users', 'email'))->ignore($userId ?? null),
],
'external_auth_id' => ['string'],
'language' => ['string'],
'language' => ['string', 'max:15', 'alpha_dash'],
'password' => [Password::default()],
'roles' => ['array'],
'roles.*' => ['integer'],

View File

@@ -15,16 +15,10 @@ use Illuminate\Validation\ValidationException;
class AttachmentController extends Controller
{
protected AttachmentService $attachmentService;
protected PageRepo $pageRepo;
/**
* AttachmentController constructor.
*/
public function __construct(AttachmentService $attachmentService, PageRepo $pageRepo)
{
$this->attachmentService = $attachmentService;
$this->pageRepo = $pageRepo;
public function __construct(
protected AttachmentService $attachmentService,
protected PageRepo $pageRepo
) {
}
/**
@@ -112,7 +106,7 @@ class AttachmentController extends Controller
try {
$this->validate($request, [
'attachment_edit_name' => ['required', 'string', 'min:1', 'max:255'],
'attachment_edit_url' => ['string', 'min:1', 'max:255', 'safe_url'],
'attachment_edit_url' => ['string', 'min:1', 'max:2000', 'safe_url'],
]);
} catch (ValidationException $exception) {
return response()->view('attachments.manager-edit-form', array_merge($request->only(['attachment_edit_name', 'attachment_edit_url']), [
@@ -148,7 +142,7 @@ class AttachmentController extends Controller
$this->validate($request, [
'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'attachment_link_name' => ['required', 'string', 'min:1', 'max:255'],
'attachment_link_url' => ['required', 'string', 'min:1', 'max:255', 'safe_url'],
'attachment_link_url' => ['required', 'string', 'min:1', 'max:2000', 'safe_url'],
]);
} catch (ValidationException $exception) {
return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [

View File

@@ -3,6 +3,8 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\Activity;
use BookStack\Actions\ActivityType;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -13,10 +15,15 @@ class AuditLogController extends Controller
$this->checkPermission('settings-manage');
$this->checkPermission('users-manage');
$listDetails = [
'order' => $request->get('order', 'desc'),
$sort = $request->get('sort', 'activity_date');
$order = $request->get('order', 'desc');
$listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
'created_at' => trans('settings.audit_table_date'),
'type' => trans('settings.audit_table_event'),
]);
$filters = [
'event' => $request->get('event', ''),
'sort' => $request->get('sort', 'created_at'),
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'user' => $request->get('user', ''),
@@ -25,39 +32,38 @@ class AuditLogController extends Controller
$query = Activity::query()
->with([
'entity' => function ($query) {
$query->withTrashed();
},
'entity' => fn ($query) => $query->withTrashed(),
'user',
])
->orderBy($listDetails['sort'], $listDetails['order']);
->orderBy($listOptions->getSort(), $listOptions->getOrder());
if ($listDetails['event']) {
$query->where('type', '=', $listDetails['event']);
if ($filters['event']) {
$query->where('type', '=', $filters['event']);
}
if ($listDetails['user']) {
$query->where('user_id', '=', $listDetails['user']);
if ($filters['user']) {
$query->where('user_id', '=', $filters['user']);
}
if ($listDetails['date_from']) {
$query->where('created_at', '>=', $listDetails['date_from']);
if ($filters['date_from']) {
$query->where('created_at', '>=', $filters['date_from']);
}
if ($listDetails['date_to']) {
$query->where('created_at', '<=', $listDetails['date_to']);
if ($filters['date_to']) {
$query->where('created_at', '<=', $filters['date_to']);
}
if ($listDetails['ip']) {
$query->where('ip', 'like', $listDetails['ip'] . '%');
if ($filters['ip']) {
$query->where('ip', 'like', $filters['ip'] . '%');
}
$activities = $query->paginate(100);
$activities->appends($listDetails);
$activities->appends($request->all());
$types = DB::table('activities')->select('type')->distinct()->pluck('type');
$types = ActivityType::all();
$this->setPageTitle(trans('settings.audit'));
return view('settings.audit', [
'activities' => $activities,
'listDetails' => $listDetails,
'filters' => $filters,
'listOptions' => $listOptions,
'activityTypes' => $types,
]);
}

View File

@@ -14,9 +14,9 @@ use Illuminate\Http\Request;
class ConfirmEmailController extends Controller
{
protected $emailConfirmationService;
protected $loginService;
protected $userRepo;
protected EmailConfirmationService $emailConfirmationService;
protected LoginService $loginService;
protected UserRepo $userRepo;
/**
* Create a new controller instance.
@@ -51,14 +51,28 @@ class ConfirmEmailController extends Controller
return view('auth.user-unconfirmed', ['user' => $user]);
}
/**
* Show the form for a user to provide their positive confirmation of their email.
*/
public function showAcceptForm(string $token)
{
return view('auth.register-confirm-accept', ['token' => $token]);
}
/**
* Confirms an email via a token and logs the user into the system.
*
* @throws ConfirmationEmailException
* @throws Exception
*/
public function confirm(string $token)
public function confirm(Request $request)
{
$validated = $this->validate($request, [
'token' => ['required', 'string']
]);
$token = $validated['token'];
try {
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
} catch (UserTokenNotFoundException $exception) {

View File

@@ -4,25 +4,11 @@ namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
class ForgotPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset emails and
| includes a trait which assists in sending these notifications from
| your application to your users. Feel free to explore this trait.
|
*/
use SendsPasswordResetEmails;
/**
* Create a new controller instance.
*
@@ -34,6 +20,14 @@ class ForgotPasswordController extends Controller
$this->middleware('guard:standard');
}
/**
* Display the form to request a password reset link.
*/
public function showLinkRequestForm()
{
return view('auth.passwords.email');
}
/**
* Send a reset link to the given user.
*
@@ -50,7 +44,7 @@ class ForgotPasswordController extends Controller
// 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.
$response = $this->broker()->sendResetLink(
$response = Password::broker()->sendResetLink(
$request->only('email')
);

View File

@@ -8,30 +8,14 @@ use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Facades\Activity;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
/*
|--------------------------------------------------------------------------
| Login Controller
|--------------------------------------------------------------------------
|
| This controller handles authenticating users for the application and
| redirecting them to your home screen. The controller uses a trait
| to conveniently provide its functionality to your applications.
|
*/
use AuthenticatesUsers { logout as traitLogout; }
/**
* Redirection paths.
*/
protected $redirectTo = '/';
protected $redirectPath = '/';
use ThrottlesLogins;
protected SocialAuthService $socialAuthService;
protected LoginService $loginService;
@@ -47,21 +31,6 @@ class LoginController extends Controller
$this->socialAuthService = $socialAuthService;
$this->loginService = $loginService;
$this->redirectPath = url('/');
}
public function username()
{
return config('auth.method') === 'standard' ? 'email' : 'username';
}
/**
* Get the needed authorization credentials from the request.
*/
protected function credentials(Request $request)
{
return $request->only('username', 'email', 'password');
}
/**
@@ -97,27 +66,15 @@ class LoginController extends Controller
/**
* Handle a login request to the application.
*
* @param \Illuminate\Http\Request $request
*
* @throws \Illuminate\Validation\ValidationException
*
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
*/
public function login(Request $request)
{
$this->validateLogin($request);
$username = $request->get($this->username());
// If the class is using the ThrottlesLogins trait, we can automatically throttle
// the login attempts for this application. We'll key this by the username and
// the IP address of the client making these requests into this application.
if (method_exists($this, 'hasTooManyLoginAttempts') &&
$this->hasTooManyLoginAttempts($request)) {
$this->fireLockoutEvent($request);
// Check login throttling attempts to see if they've gone over the limit
if ($this->hasTooManyLoginAttempts($request)) {
Activity::logFailedLogin($username);
return $this->sendLockoutResponse($request);
}
@@ -131,24 +88,62 @@ class LoginController extends Controller
return $this->sendLoginAttemptExceptionResponse($exception, $request);
}
// If the login attempt was unsuccessful we will increment the number of attempts
// to login and redirect the user back to the login form. Of course, when this
// user surpasses their maximum number of attempts they will get locked out.
// On unsuccessful login attempt, Increment login attempts for throttling and log failed login.
$this->incrementLoginAttempts($request);
Activity::logFailedLogin($username);
return $this->sendFailedLoginResponse($request);
// Throw validation failure for failed login
throw ValidationException::withMessages([
$this->username() => [trans('auth.failed')],
])->redirectTo('/login');
}
/**
* Logout user and perform subsequent redirect.
*/
public function logout(Request $request)
{
Auth::guard()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
return redirect($redirectUri);
}
/**
* Get the expected username input based upon the current auth method.
*/
protected function username(): string
{
return config('auth.method') === 'standard' ? 'email' : 'username';
}
/**
* Get the needed authorization credentials from the request.
*/
protected function credentials(Request $request): array
{
return $request->only('username', 'email', 'password');
}
/**
* Send the response after the user was authenticated.
* @return RedirectResponse
*/
protected function sendLoginResponse(Request $request)
{
$request->session()->regenerate();
$this->clearLoginAttempts($request);
return redirect()->intended('/');
}
/**
* Attempt to log the user into the application.
*
* @param \Illuminate\Http\Request $request
*
* @return bool
*/
protected function attemptLogin(Request $request)
protected function attemptLogin(Request $request): bool
{
return $this->loginService->attempt(
$this->credentials($request),
@@ -157,29 +152,12 @@ class LoginController extends Controller
);
}
/**
* The user has been authenticated.
*
* @param \Illuminate\Http\Request $request
* @param mixed $user
*
* @return mixed
*/
protected function authenticated(Request $request, $user)
{
return redirect()->intended($this->redirectPath());
}
/**
* Validate the user login request.
*
* @param \Illuminate\Http\Request $request
*
* @throws \Illuminate\Validation\ValidationException
*
* @return void
* @throws ValidationException
*/
protected function validateLogin(Request $request)
protected function validateLogin(Request $request): void
{
$rules = ['password' => ['required', 'string']];
$authMethod = config('auth.method');
@@ -213,22 +191,6 @@ class LoginController extends Controller
return redirect('/login');
}
/**
* Get the failed login response instance.
*
* @param \Illuminate\Http\Request $request
*
* @throws \Illuminate\Validation\ValidationException
*
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function sendFailedLoginResponse(Request $request)
{
throw ValidationException::withMessages([
$this->username() => [trans('auth.failed')],
])->redirectTo('/login');
}
/**
* Update the intended URL location from their previous URL.
* Ignores if not from the current app instance or if from certain
@@ -268,20 +230,4 @@ class LoginController extends Controller
return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
}
/**
* Logout user and perform subsequent redirect.
*
* @param \Illuminate\Http\Request $request
*
* @return mixed
*/
public function logout(Request $request)
{
$this->traitLogout($request);
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
return redirect($redirectUri);
}
}

View File

@@ -5,42 +5,19 @@ namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\User;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
/*
|--------------------------------------------------------------------------
| Register Controller
|--------------------------------------------------------------------------
|
| This controller handles the registration of new users as well as their
| validation and creation. By default this controller uses a trait to
| provide this functionality without requiring any additional code.
|
*/
use RegistersUsers;
protected $socialAuthService;
protected $registrationService;
protected $loginService;
/**
* Where to redirect users after login / registration.
*
* @var string
*/
protected $redirectTo = '/';
protected $redirectPath = '/';
protected SocialAuthService $socialAuthService;
protected RegistrationService $registrationService;
protected LoginService $loginService;
/**
* Create a new controller instance.
@@ -56,23 +33,6 @@ class RegisterController extends Controller
$this->socialAuthService = $socialAuthService;
$this->registrationService = $registrationService;
$this->loginService = $loginService;
$this->redirectTo = url('/');
$this->redirectPath = url('/');
}
/**
* Get a validator for an incoming registration request.
*
* @return \Illuminate\Contracts\Validation\Validator
*/
protected function validator(array $data)
{
return Validator::make($data, [
'name' => ['required', 'min:2', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => ['required', Password::default()],
]);
}
/**
@@ -115,22 +75,18 @@ class RegisterController extends Controller
$this->showSuccessNotification(trans('auth.register_success'));
return redirect($this->redirectPath());
return redirect('/');
}
/**
* Create a new user instance after a valid registration.
*
* @param array $data
*
* @return User
* Get a validator for an incoming registration request.
*/
protected function create(array $data)
protected function validator(array $data): ValidatorContract
{
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
return Validator::make($data, [
'name' => ['required', 'min:2', 'max:100'],
'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => ['required', Password::default()],
]);
}
}

View File

@@ -3,66 +3,87 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\User;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
class ResetPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset requests
| and uses a simple trait to include this behavior. You're free to
| explore this trait and override any methods you wish to tweak.
|
*/
protected LoginService $loginService;
use ResetsPasswords;
protected $redirectTo = '/';
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
public function __construct(LoginService $loginService)
{
$this->middleware('guest');
$this->middleware('guard:standard');
$this->loginService = $loginService;
}
/**
* Display the password reset view for the given token.
* If no token is present, display the link request form.
*/
public function showResetForm(Request $request)
{
$token = $request->route()->parameter('token');
return view('auth.passwords.reset')->with(
['token' => $token, 'email' => $request->email]
);
}
/**
* Reset the given user's password.
*/
public function reset(Request $request)
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', PasswordRule::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
$response = Password::broker()->reset($credentials, function (User $user, string $password) {
$user->password = Hash::make($password);
$user->setRememberToken(Str::random(60));
$user->save();
$this->loginService->login($user, auth()->getDefaultDriver());
});
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $response === Password::PASSWORD_RESET
? $this->sendResetResponse()
: $this->sendResetFailedResponse($request, $response);
}
/**
* Get the response for a successful password reset.
*
* @param Request $request
* @param string $response
*
* @return \Illuminate\Http\Response
*/
protected function sendResetResponse(Request $request, $response)
protected function sendResetResponse(): RedirectResponse
{
$message = trans('auth.reset_password_success');
$this->showSuccessNotification($message);
$this->showSuccessNotification(trans('auth.reset_password_success'));
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user());
return redirect($this->redirectPath())
->with('status', trans($response));
return redirect('/');
}
/**
* Get the response for a failed password reset.
*
* @param \Illuminate\Http\Request $request
* @param string $response
*
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
*/
protected function sendResetFailedResponse(Request $request, $response)
protected function sendResetFailedResponse(Request $request, string $response): RedirectResponse
{
// We show invalid users as invalid tokens as to not leak what
// users may exist in the system.

View File

@@ -9,7 +9,7 @@ use Illuminate\Support\Str;
class Saml2Controller extends Controller
{
protected $samlService;
protected Saml2Service $samlService;
/**
* Saml2Controller constructor.

View File

@@ -16,9 +16,9 @@ use Laravel\Socialite\Contracts\User as SocialUser;
class SocialController extends Controller
{
protected $socialAuthService;
protected $registrationService;
protected $loginService;
protected SocialAuthService $socialAuthService;
protected RegistrationService $registrationService;
protected LoginService $loginService;
/**
* SocialController constructor.
@@ -28,7 +28,7 @@ class SocialController extends Controller
RegistrationService $registrationService,
LoginService $loginService
) {
$this->middleware('guest')->only(['getRegister', 'postRegister']);
$this->middleware('guest')->only(['register']);
$this->socialAuthService = $socialAuthService;
$this->registrationService = $registrationService;
$this->loginService = $loginService;

View File

@@ -0,0 +1,92 @@
<?php
namespace BookStack\Http\Controllers\Auth;
use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
trait ThrottlesLogins
{
/**
* Determine if the user has too many failed login attempts.
*/
protected function hasTooManyLoginAttempts(Request $request): bool
{
return $this->limiter()->tooManyAttempts(
$this->throttleKey($request),
$this->maxAttempts()
);
}
/**
* Increment the login attempts for the user.
*/
protected function incrementLoginAttempts(Request $request): void
{
$this->limiter()->hit(
$this->throttleKey($request),
$this->decayMinutes() * 60
);
}
/**
* Redirect the user after determining they are locked out.
* @throws ValidationException
*/
protected function sendLockoutResponse(Request $request): \Symfony\Component\HttpFoundation\Response
{
$seconds = $this->limiter()->availableIn(
$this->throttleKey($request)
);
throw ValidationException::withMessages([
$this->username() => [trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
])],
])->status(Response::HTTP_TOO_MANY_REQUESTS);
}
/**
* Clear the login locks for the given user credentials.
*/
protected function clearLoginAttempts(Request $request): void
{
$this->limiter()->clear($this->throttleKey($request));
}
/**
* Get the throttle key for the given request.
*/
protected function throttleKey(Request $request): string
{
return Str::transliterate(Str::lower($request->input($this->username())) . '|' . $request->ip());
}
/**
* Get the rate limiter instance.
*/
protected function limiter(): RateLimiter
{
return app(RateLimiter::class);
}
/**
* Get the maximum number of attempts to allow.
*/
public function maxAttempts(): int
{
return 5;
}
/**
* Get the number of minutes to throttle for.
*/
public function decayMinutes(): int
{
return 1;
}
}

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