Compare commits

...

621 Commits

Author SHA1 Message Date
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
Dan Brown
49200ca5ce Updated version and assets for release v22.07 2022-07-28 14:53:15 +01:00
Dan Brown
34aa4dbf10 Merge branch 'development' into release 2022-07-28 14:53:01 +01:00
Dan Brown
a21d09fed7 New Crowdin updates (#3600) 2022-07-28 14:12:13 +01:00
Dan Brown
50bc2e49c1 Update translators.txt 2022-07-28 14:10:14 +01:00
Dan Brown
8776113210 Updated translator attribution pre 22.07 release 2022-07-28 14:01:27 +01:00
Dan Brown
397a36cfd0 Merge branch 'lang_de' into development 2022-07-27 11:20:08 +01:00
Dan Brown
ee24635e06 Merge pull request #3556 from GongMingCai/development
Fixed comment count update error
2022-07-27 11:18:05 +01:00
Dan Brown
7c8368cc63 Merge pull request #3545 from BookStackApp/l10n_development
New Crowdin updates
2022-07-27 11:15:45 +01:00
Dan Brown
f93e380d19 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2022-07-27 11:08:03 +01:00
Dan Brown
0bb5654f80 Updated composer deps, applied StyleCI changes 2022-07-27 11:07:41 +01:00
Dan Brown
89324bf9cc Merge pull request #3599 from BookStackApp/editor_list_shortcuts
Add editor shortcuts for two main list types
2022-07-27 11:03:08 +01:00
Dan Brown
9abb207e4d Added list shortcuts to markdown editor
Added some logic for ordered lists to continue the numbering logic,
while keeping the number list format style the same [1. vs 1)]
2022-07-27 11:01:37 +01:00
Dan Brown
8aad8e4a24 New translations entities.php (Spanish) 2022-07-26 20:14:59 +01:00
Dan Brown
8681c5f613 Added ordered/unordered WYSIWYG list shortcuts
Related to #1269
2022-07-26 16:43:15 +01:00
Dan Brown
944ac2e6eb New translations entities.php (German Informal) 2022-07-26 13:13:26 +01:00
Dan Brown
75759fb735 New translations entities.php (Dutch) 2022-07-26 13:13:25 +01:00
Dan Brown
f47c9a53aa New translations entities.php (Lithuanian) 2022-07-26 13:13:24 +01:00
Dan Brown
480d591acf New translations entities.php (Korean) 2022-07-26 13:13:23 +01:00
Dan Brown
ae40ec10a4 New translations entities.php (Japanese) 2022-07-26 13:13:22 +01:00
Dan Brown
180927cdb9 New translations entities.php (Italian) 2022-07-26 13:13:21 +01:00
Dan Brown
f37e7186d9 New translations entities.php (Hungarian) 2022-07-26 13:13:20 +01:00
Dan Brown
bb7bd903ef New translations entities.php (Hebrew) 2022-07-26 13:13:19 +01:00
Dan Brown
6c767cd205 New translations entities.php (Polish) 2022-07-26 13:13:18 +01:00
Dan Brown
99aa093e2b New translations entities.php (Basque) 2022-07-26 13:13:17 +01:00
Dan Brown
42b576df55 New translations entities.php (Czech) 2022-07-26 13:13:16 +01:00
Dan Brown
3fbe304cfc New translations entities.php (Catalan) 2022-07-26 13:13:15 +01:00
Dan Brown
e0d4a43e1e New translations entities.php (Bulgarian) 2022-07-26 13:13:14 +01:00
Dan Brown
ab6a3144ec New translations entities.php (Arabic) 2022-07-26 13:13:13 +01:00
Dan Brown
7a2f54b890 New translations entities.php (Spanish) 2022-07-26 13:13:12 +01:00
Dan Brown
8f28bb9e3c New translations entities.php (French) 2022-07-26 13:13:11 +01:00
Dan Brown
cb12f76f46 New translations entities.php (German) 2022-07-26 13:13:10 +01:00
Dan Brown
3f1b376b2b New translations entities.php (Danish) 2022-07-26 13:13:09 +01:00
Dan Brown
147f038806 New translations entities.php (Ukrainian) 2022-07-26 13:13:08 +01:00
Dan Brown
812675dfc2 New translations entities.php (Russian) 2022-07-26 13:13:06 +01:00
Dan Brown
3b2fb67d78 New translations entities.php (Slovenian) 2022-07-26 13:13:05 +01:00
Dan Brown
ae91831ba6 New translations entities.php (Norwegian Bokmal) 2022-07-26 13:13:04 +01:00
Dan Brown
fcfafbdac5 New translations entities.php (Uzbek) 2022-07-26 13:13:02 +01:00
Dan Brown
705f81561a New translations entities.php (Bosnian) 2022-07-26 13:13:01 +01:00
Dan Brown
1c70684a99 New translations entities.php (Welsh) 2022-07-26 13:13:00 +01:00
Dan Brown
32e305ef4f New translations entities.php (Latvian) 2022-07-26 13:12:59 +01:00
Dan Brown
8c70a69fff New translations entities.php (Estonian) 2022-07-26 13:12:58 +01:00
Dan Brown
f0eb4df1e9 New translations entities.php (Croatian) 2022-07-26 13:12:57 +01:00
Dan Brown
852f4e61a5 New translations entities.php (Slovak) 2022-07-26 13:12:56 +01:00
Dan Brown
d68ee461e0 New translations entities.php (Spanish, Argentina) 2022-07-26 13:12:55 +01:00
Dan Brown
98ce7a0675 New translations entities.php (Indonesian) 2022-07-26 13:12:54 +01:00
Dan Brown
e6e1b9423d New translations entities.php (Portuguese, Brazilian) 2022-07-26 13:12:52 +01:00
Dan Brown
b3c93a2188 New translations entities.php (Vietnamese) 2022-07-26 13:12:51 +01:00
Dan Brown
59dbc0b9f1 New translations entities.php (Chinese Traditional) 2022-07-26 13:12:50 +01:00
Dan Brown
7a43b6d5b7 New translations entities.php (Chinese Simplified) 2022-07-26 13:12:49 +01:00
Dan Brown
fb8f92e835 New translations entities.php (Turkish) 2022-07-26 13:12:48 +01:00
Dan Brown
0d36e3fecf New translations entities.php (Swedish) 2022-07-26 13:12:47 +01:00
Dan Brown
b878ccc361 New translations entities.php (Persian) 2022-07-26 13:12:46 +01:00
Dan Brown
2bab892dce New translations entities.php (Portuguese) 2022-07-26 13:12:45 +01:00
Dan Brown
4fa73be80e Merge pull request #3598 from BookStackApp/chapter_sort_book_option
Added 'Sort Book' action to chapters
2022-07-26 12:37:18 +01:00
Dan Brown
bd14dc067b Added 'Sort Book' action to chapters
Related to #2335
2022-07-26 12:36:17 +01:00
Dan Brown
d4a119b2aa Fixed disabling of avatar urls, Removed id from gravatar image name
Included test to cover avatar url disabling.
Related to #1835
2022-07-26 12:10:19 +01:00
Dan Brown
2ec8a33927 Removed labels from WYSIWYG colors
To ease burden of translation.

Related to #3530
2022-07-26 11:07:40 +01:00
Dan Brown
fee3022ad8 Added tinymce de-focus toolbar hack for drawing editor load
The tinymce event system would not pick up the focus within the loaded
draw.io instance, after the drawing toolbar button was clicked, hence
the toolbar would hang around.

This adds a hack to dispatch a mousedown event on the body to get the
toolbar to hide.

For #3597
2022-07-25 19:56:01 +01:00
Dan Brown
050ae01f94 Merge pull request #3593 from BookStackApp/code-editor-favorites
Code-editor lang favorites system
2022-07-25 19:16:11 +01:00
Dan Brown
8e5f7c6425 Added language list favourites sorting, updated styles
- Also made code box be greedier with vertical space.
2022-07-25 19:13:25 +01:00
Dan Brown
7fdc7c68b9 Added test to cover code favourite pref. endpoint 2022-07-25 18:48:40 +01:00
Dan Brown
017c7659e5 New translations editor.php (Estonian) 2022-07-25 16:03:40 +01:00
Dan Brown
a08ea54615 New translations entities.php (Estonian) 2022-07-25 16:03:39 +01:00
Dan Brown
0df5ae0658 Added core code-lang-favourites JS, PHP & CSS logic
- Got the functionality now working to favourite items and store that
  status within the system for the user.
- Improved CSS display for usability.
2022-07-25 13:10:27 +01:00
Dan Brown
3fa43c804b New translations activities.php (Indonesian) 2022-07-25 00:23:51 +01:00
Dan Brown
ebc5a53410 Started code-editor lang favorites system
- Split bash from shell in language list
- Updated code-lang highlighting to be exact match only to prevent
  confusion scenarios (Java matching JavaScript, etc..)
- Added design for favorites
- Changed blade language list to be generated from array.
2022-07-24 21:15:43 +01:00
Dan Brown
62500a9bfa New translations editor.php (Dutch) 2022-07-24 19:51:07 +01:00
Dan Brown
a5153ff5af New translations entities.php (Dutch) 2022-07-24 19:51:06 +01:00
Dan Brown
3734b0a37e New translations editor.php (Spanish) 2022-07-24 15:39:46 +01:00
Dan Brown
4d72ac16a3 New translations entities.php (Spanish) 2022-07-24 15:39:46 +01:00
Dan Brown
29404f7e38 New translations entities.php (German Informal) 2022-07-24 12:39:49 +01:00
Dan Brown
a7252301c1 New translations entities.php (Dutch) 2022-07-24 12:39:48 +01:00
Dan Brown
0825dd17cf New translations entities.php (Lithuanian) 2022-07-24 12:39:47 +01:00
Dan Brown
9dd51c7cff New translations entities.php (Korean) 2022-07-24 12:39:46 +01:00
Dan Brown
854d2fe2dc New translations entities.php (Japanese) 2022-07-24 12:39:45 +01:00
Dan Brown
27848cea75 New translations entities.php (Italian) 2022-07-24 12:39:44 +01:00
Dan Brown
d1d999a98a New translations entities.php (Hungarian) 2022-07-24 12:39:43 +01:00
Dan Brown
5a6e171a7e New translations entities.php (Hebrew) 2022-07-24 12:39:42 +01:00
Dan Brown
90ffa46331 New translations entities.php (Polish) 2022-07-24 12:39:42 +01:00
Dan Brown
957214b84b New translations entities.php (Basque) 2022-07-24 12:39:41 +01:00
Dan Brown
a2bda11787 New translations entities.php (Czech) 2022-07-24 12:39:40 +01:00
Dan Brown
56204963e7 New translations entities.php (Catalan) 2022-07-24 12:39:39 +01:00
Dan Brown
ee579115b6 New translations entities.php (Bulgarian) 2022-07-24 12:39:38 +01:00
Dan Brown
d431141918 New translations entities.php (Arabic) 2022-07-24 12:39:37 +01:00
Dan Brown
4ff6b7fc51 New translations entities.php (Spanish) 2022-07-24 12:39:36 +01:00
Dan Brown
5d42f36a2b New translations entities.php (French) 2022-07-24 12:39:35 +01:00
Dan Brown
fb3491092e New translations entities.php (German) 2022-07-24 12:39:34 +01:00
Dan Brown
812c65fa3c New translations entities.php (Danish) 2022-07-24 12:39:33 +01:00
Dan Brown
9b48ee90f0 New translations entities.php (Ukrainian) 2022-07-24 12:39:32 +01:00
Dan Brown
6ad6bcaf82 New translations entities.php (Russian) 2022-07-24 12:39:31 +01:00
Dan Brown
f4ef85d587 New translations entities.php (Slovenian) 2022-07-24 12:39:30 +01:00
Dan Brown
1c57223a2d New translations entities.php (Norwegian Bokmal) 2022-07-24 12:39:29 +01:00
Dan Brown
a1a900035b New translations entities.php (Uzbek) 2022-07-24 12:39:28 +01:00
Dan Brown
bc8d4c40da New translations entities.php (Bosnian) 2022-07-24 12:39:28 +01:00
Dan Brown
cb91bd4933 New translations entities.php (Welsh) 2022-07-24 12:39:27 +01:00
Dan Brown
f336ce9119 New translations entities.php (Latvian) 2022-07-24 12:39:26 +01:00
Dan Brown
f9e040658d New translations entities.php (Estonian) 2022-07-24 12:39:25 +01:00
Dan Brown
d1b9d62e40 New translations entities.php (Croatian) 2022-07-24 12:39:24 +01:00
Dan Brown
7e70c14a16 New translations entities.php (Slovak) 2022-07-24 12:39:23 +01:00
Dan Brown
1b7a1e847e New translations entities.php (Spanish, Argentina) 2022-07-24 12:39:22 +01:00
Dan Brown
011178c302 New translations entities.php (Indonesian) 2022-07-24 12:39:21 +01:00
Dan Brown
e27b53fc6c New translations entities.php (Portuguese, Brazilian) 2022-07-24 12:39:20 +01:00
Dan Brown
260c9d528f New translations entities.php (Vietnamese) 2022-07-24 12:39:19 +01:00
Dan Brown
14b5c39e71 New translations entities.php (Chinese Traditional) 2022-07-24 12:39:18 +01:00
Dan Brown
4c1256f02a New translations entities.php (Chinese Simplified) 2022-07-24 12:39:16 +01:00
Dan Brown
a7f7200478 New translations entities.php (Turkish) 2022-07-24 12:39:15 +01:00
Dan Brown
f2088d3a56 New translations entities.php (Swedish) 2022-07-24 12:39:14 +01:00
Dan Brown
affd8df594 New translations entities.php (Persian) 2022-07-24 12:39:13 +01:00
Dan Brown
bc40601d7d New translations entities.php (Portuguese) 2022-07-24 12:39:12 +01:00
Dan Brown
da6169159d Merge pull request #3591 from BookStackApp/shelf_books_enhancements
Improved shelf book management interface
2022-07-24 12:28:01 +01:00
Dan Brown
b0adb74d62 Improved shelf book management interface
- Added ability to search books list (Local simple text match).
- Added handles, hover-states and cursor states for better user
  interaction and clearer use of drag & drop.
- Improved styles for dark mode.
- Converted shelf sort component to newer component format.
- Modernized shelf controller code a little.

Related to #3266
2022-07-24 12:23:25 +01:00
Dan Brown
f004cb69d7 New translations editor.php (French) 2022-07-23 19:12:14 +01:00
Dan Brown
975ba4f8d8 Added content-view body classes generated from tags
Included tests to cover.

Closes #3583
2022-07-23 18:29:04 +01:00
Dan Brown
468040edc4 New translations activities.php (Slovak) 2022-07-23 17:51:24 +01:00
Dan Brown
840a1ea011 Applied latest styleci changes 2022-07-23 15:11:06 +01:00
Dan Brown
72c8b138e1 Updated tests to use ssddanbrown/asserthtml package
Closes #3519
2022-07-23 15:10:18 +01:00
Dan Brown
cf73e5f2c6 Tweaked wording aroung the IP address precision option 2022-07-23 13:46:13 +01:00
Dan Brown
4e8995c3d0 Added ability to adjust stored IP address precision
Included tests to cover.

For #3560
2022-07-23 13:41:29 +01:00
Dan Brown
67d12cc1df Fixed failing license test 2022-07-23 12:08:55 +01:00
Dan Brown
7931ab1b91 New translations editor.php (German Informal) 2022-07-23 11:43:10 +01:00
Dan Brown
137beb4002 New translations editor.php (Polish) 2022-07-23 11:43:09 +01:00
Dan Brown
2354ce49ba New translations editor.php (Dutch) 2022-07-23 11:43:08 +01:00
Dan Brown
d0925e0e91 New translations editor.php (Lithuanian) 2022-07-23 11:43:07 +01:00
Dan Brown
69473d28f3 New translations editor.php (Korean) 2022-07-23 11:43:06 +01:00
Dan Brown
11cf9fd832 New translations editor.php (Japanese) 2022-07-23 11:43:06 +01:00
Dan Brown
c89865b574 New translations editor.php (Italian) 2022-07-23 11:43:05 +01:00
Dan Brown
8a1fb300fe New translations editor.php (Hungarian) 2022-07-23 11:43:04 +01:00
Dan Brown
8c4ed9e0b7 New translations editor.php (Portuguese) 2022-07-23 11:43:03 +01:00
Dan Brown
c4f3a71652 New translations editor.php (Hebrew) 2022-07-23 11:43:02 +01:00
Dan Brown
c5259d0195 New translations editor.php (German) 2022-07-23 11:43:01 +01:00
Dan Brown
3899b44622 New translations editor.php (Danish) 2022-07-23 11:43:00 +01:00
Dan Brown
58057855f8 New translations editor.php (Czech) 2022-07-23 11:42:59 +01:00
Dan Brown
4cdaa1ad99 New translations editor.php (Catalan) 2022-07-23 11:42:58 +01:00
Dan Brown
c737fa8a6b New translations editor.php (Bulgarian) 2022-07-23 11:42:58 +01:00
Dan Brown
c5a0b99d20 New translations editor.php (Arabic) 2022-07-23 11:42:57 +01:00
Dan Brown
554d706468 New translations editor.php (Spanish) 2022-07-23 11:42:56 +01:00
Dan Brown
4591583deb New translations editor.php (Basque) 2022-07-23 11:42:55 +01:00
Dan Brown
05f9f8f969 New translations editor.php (French) 2022-07-23 11:42:54 +01:00
Dan Brown
1f7f26bd29 New translations editor.php (Russian) 2022-07-23 11:42:53 +01:00
Dan Brown
1e028f51eb New translations editor.php (Slovenian) 2022-07-23 11:42:52 +01:00
Dan Brown
7389b33980 New translations editor.php (Norwegian Bokmal) 2022-07-23 11:42:52 +01:00
Dan Brown
70fccfd8d3 New translations editor.php (Uzbek) 2022-07-23 11:42:51 +01:00
Dan Brown
9ccee6707d New translations editor.php (Bosnian) 2022-07-23 11:42:50 +01:00
Dan Brown
2de804950b New translations editor.php (Welsh) 2022-07-23 11:42:49 +01:00
Dan Brown
e4e130a5da New translations editor.php (Latvian) 2022-07-23 11:42:48 +01:00
Dan Brown
3101d76726 New translations editor.php (Estonian) 2022-07-23 11:42:47 +01:00
Dan Brown
694da007b6 New translations editor.php (Croatian) 2022-07-23 11:42:46 +01:00
Dan Brown
ea2aa626a9 New translations editor.php (Slovak) 2022-07-23 11:42:45 +01:00
Dan Brown
9ab485093e New translations editor.php (Spanish, Argentina) 2022-07-23 11:42:44 +01:00
Dan Brown
93d9c77595 New translations editor.php (Indonesian) 2022-07-23 11:42:43 +01:00
Dan Brown
71e760e345 New translations editor.php (Portuguese, Brazilian) 2022-07-23 11:42:43 +01:00
Dan Brown
34d15230dc New translations editor.php (Vietnamese) 2022-07-23 11:42:42 +01:00
Dan Brown
af5517ad59 New translations editor.php (Chinese Traditional) 2022-07-23 11:42:41 +01:00
Dan Brown
002b093e82 New translations editor.php (Ukrainian) 2022-07-23 11:42:40 +01:00
Dan Brown
2e9000b18c New translations editor.php (Turkish) 2022-07-23 11:42:39 +01:00
Dan Brown
b24f5b7392 New translations editor.php (Swedish) 2022-07-23 11:42:38 +01:00
Dan Brown
3caf308f6b New translations editor.php (Persian) 2022-07-23 11:42:37 +01:00
Dan Brown
e01baac15d New translations editor.php (Chinese Simplified) 2022-07-23 11:42:36 +01:00
Dan Brown
f573e09004 Applied styleci changes, updated dev version & readme roadmap 2022-07-23 11:36:37 +01:00
Dan Brown
f4dd38ea94 Merge pull request #3580 from BookStackApp/tinymce6
TinyMCE6 update
2022-07-23 11:33:51 +01:00
Dan Brown
aad22384cb Enabled modern tinymce table features 2022-07-23 11:32:26 +01:00
Dan Brown
8176ca153a Fixed blue wyswiyg toolbar in dark mode 2022-07-23 11:22:34 +01:00
Dan Brown
f86bb27a83 Ensured wysiwyg details contents are wrapped in block elements
Fixes issue where inline-only content would disappear when unwrapping a
details block element.
2022-07-23 11:18:03 +01:00
Dan Brown
a9ee2e6889 Removed toolbar dialog background line 2022-07-23 10:43:47 +01:00
Dan Brown
d9f0c9eee8 New translations entities.php (French) 2022-07-20 08:32:06 +01:00
Dan Brown
7b508dac3d New translations editor.php (Chinese Simplified) 2022-07-18 18:51:48 +01:00
Dan Brown
3ca64da4a5 New translations entities.php (Italian) 2022-07-18 15:55:46 +01:00
Dan Brown
6a6f00058f Added back in image options context toolbar item 2022-07-18 13:37:50 +01:00
Dan Brown
cd929b2555 Made a bunch of tinymce 6 upgrade fixes
- Added workaround for new 'srcdoc' usage that's breaking content in
  Firefox, added new 'custom-changes.md' file to document for future.
- Updated old usages of 'new' when creating nodes.
- Tested and changed logic, where required, where 'editor.dom.select'
  has been used to replace the old '$' usages.
- Fixed bad boolean value being passed to 'setActive' in task list
  logic.
2022-07-18 13:18:46 +01:00
Dan Brown
400e584911 New translations activities.php (Norwegian Bokmal) 2022-07-18 10:54:22 +01:00
Dan Brown
9c90e798df New translations entities.php (Spanish) 2022-07-17 23:19:49 +01:00
Dan Brown
c519f707e8 Started upgrade to TinyMCE6, Untested
- Merged in latest TinyMCE code.
- Gone through tinymce upgrade guide, made required config changes.
- Altered license references.
2022-07-17 18:33:03 +01:00
Dan Brown
e024b03a61 New translations entities.php (Chinese Simplified) 2022-07-17 17:19:51 +01:00
Dan Brown
d9e9c1735a Merge pull request #3579 from BookStackApp/dompdf_and_php_deps
Updated DOMPDF, and other PHP dependancies
2022-07-17 14:40:19 +01:00
Dan Brown
56da25b07a Fixed failing tests from dompdf chanages 2022-07-17 14:32:09 +01:00
Dan Brown
24f4febcd5 Updated DOMPDF, and other PHP dependancies 2022-07-17 14:01:59 +01:00
Dan Brown
5f5b6ff0be Added "ACTIVITY_LOGGED" theme event
Closes #3572
2022-07-17 13:28:56 +01:00
Dan Brown
8f9923c7c1 Re-ordered theme events to be alphabetical 2022-07-17 13:08:44 +01:00
Dan Brown
7be7caacd5 New translations entities.php (German Informal) 2022-07-17 10:50:26 +01:00
Dan Brown
bcd06c1d56 New translations entities.php (Chinese Simplified) 2022-07-17 10:50:26 +01:00
Dan Brown
e01a0e61d9 New translations entities.php (Chinese Traditional) 2022-07-17 10:50:24 +01:00
Dan Brown
f0049e346b New translations entities.php (Portuguese, Brazilian) 2022-07-17 10:50:24 +01:00
Dan Brown
b7f84171c6 New translations entities.php (Indonesian) 2022-07-17 10:50:23 +01:00
Dan Brown
cb5b4392f4 New translations entities.php (Persian) 2022-07-17 10:50:22 +01:00
Dan Brown
4eb76699a9 New translations entities.php (Spanish, Argentina) 2022-07-17 10:50:21 +01:00
Dan Brown
a48a1d80ae New translations entities.php (Croatian) 2022-07-17 10:50:20 +01:00
Dan Brown
b3b8da0fe7 New translations entities.php (Vietnamese) 2022-07-17 10:50:19 +01:00
Dan Brown
a2440e20bc New translations entities.php (Latvian) 2022-07-17 10:50:18 +01:00
Dan Brown
f3f72fde6b New translations entities.php (Welsh) 2022-07-17 10:50:17 +01:00
Dan Brown
9023487d99 New translations entities.php (Bosnian) 2022-07-17 10:50:16 +01:00
Dan Brown
d77c0d3ddd New translations entities.php (Uzbek) 2022-07-17 10:50:15 +01:00
Dan Brown
cffa0a0cf3 New translations entities.php (Norwegian Bokmal) 2022-07-17 10:50:14 +01:00
Dan Brown
7b4e36eb38 New translations entities.php (Estonian) 2022-07-17 10:50:13 +01:00
Dan Brown
f3eb7c4208 New translations entities.php (Basque) 2022-07-17 10:50:12 +01:00
Dan Brown
2fcb0b6db2 New translations entities.php (Danish) 2022-07-17 10:50:11 +01:00
Dan Brown
ea0ac2a853 New translations entities.php (Czech) 2022-07-17 10:50:10 +01:00
Dan Brown
76049f0cdb New translations entities.php (Catalan) 2022-07-17 10:50:09 +01:00
Dan Brown
0143fe88d3 New translations entities.php (Bulgarian) 2022-07-17 10:50:08 +01:00
Dan Brown
0b89642610 New translations entities.php (Spanish) 2022-07-17 10:50:07 +01:00
Dan Brown
bc8d2d8209 New translations entities.php (French) 2022-07-17 10:50:06 +01:00
Dan Brown
f832a9545e New translations entities.php (German) 2022-07-17 10:50:05 +01:00
Dan Brown
aad2f54c15 New translations entities.php (Hebrew) 2022-07-17 10:50:04 +01:00
Dan Brown
0cd44a6e7d New translations entities.php (Arabic) 2022-07-17 10:50:03 +01:00
Dan Brown
79e386f457 New translations entities.php (Hungarian) 2022-07-17 10:50:02 +01:00
Dan Brown
387b6620e4 New translations entities.php (Russian) 2022-07-17 10:50:01 +01:00
Dan Brown
d76bdbc976 New translations entities.php (Japanese) 2022-07-17 10:50:00 +01:00
Dan Brown
89de7a60c6 New translations entities.php (Korean) 2022-07-17 10:49:59 +01:00
Dan Brown
42c6179350 New translations entities.php (Lithuanian) 2022-07-17 10:49:58 +01:00
Dan Brown
01c2c92710 New translations entities.php (Dutch) 2022-07-17 10:49:57 +01:00
Dan Brown
4b770ee2dc New translations entities.php (Polish) 2022-07-17 10:49:56 +01:00
Dan Brown
c47997bbb7 New translations entities.php (Ukrainian) 2022-07-17 10:49:55 +01:00
Dan Brown
9d78af2c1d New translations entities.php (Slovak) 2022-07-17 10:49:54 +01:00
Dan Brown
a8d933753e New translations entities.php (Slovenian) 2022-07-17 10:49:53 +01:00
Dan Brown
2853feb9c4 New translations entities.php (Swedish) 2022-07-17 10:49:52 +01:00
Dan Brown
86e7386db9 New translations entities.php (Turkish) 2022-07-17 10:49:52 +01:00
Dan Brown
ce9a788fb9 New translations entities.php (Italian) 2022-07-17 10:49:51 +01:00
Dan Brown
6b8083244d New translations entities.php (Portuguese) 2022-07-17 10:49:50 +01:00
Dan Brown
94bf7e2e0c Merge pull request #3569 from BookStackApp/permissions_v2
Permissions System Refactor
2022-07-17 10:36:33 +01:00
Dan Brown
9cf05944f6 Applied StyleCI changes 2022-07-17 10:32:16 +01:00
Dan Brown
e6e6d25974 Removed test web route, extracted text, added test 2022-07-17 10:18:24 +01:00
Dan Brown
8f90996cef Dropped use of non-view joint permissions 2022-07-16 21:50:42 +01:00
Dan Brown
2332401854 Fixed a couple of non-intended logical permission issues
Both caught in tests:
Fixed loss of permissions for admin users when entity restrictions were
active, since there are no entity-restrictions for the admin role but
we'd force generate them in joint permissions, which would be queried.
Fixed new role permission checks when permissions given with only the
action (eg. 'view'), since the type prefix would be required for role
permission checks. Was previously not needed as only the simpler form
was used in the jointpermissions after merge & calculation.
2022-07-16 20:55:32 +01:00
Dan Brown
afe1a04239 Aligned permission applicator method names
Also removed lesser used function, that was mostly a duplicate of an
existing function, and only used for search.
2022-07-16 19:54:25 +01:00
Dan Brown
f459a68535 Removed remaining dynamic action usages in joint permission queries 2022-07-16 19:28:04 +01:00
Dan Brown
1d875ccfb7 Continued removal of joint permission non-view queries
Cleaned up PermissionApplicator to remove old cache system which was
hardly ever actuall caching anything since it was reset after each
public method run.

Changed the scope of 'userCanOnAny' to just check entity permissions,
and added protections of action scope creep, in case a role permission
action was passed by mistake.
2022-07-16 13:17:08 +01:00
Dan Brown
2b4b7c68cc New translations entities.php (German) 2022-07-15 08:45:23 +01:00
Dan Brown
ad8d8dde2d New translations entities.php (German) 2022-07-15 07:39:07 +01:00
Dan Brown
23f9b4d217 New translations auth.php (German) 2022-07-15 07:39:06 +01:00
Dan Brown
bfbd0fc168 New translations activities.php (German) 2022-07-14 23:50:40 +01:00
Dan Brown
77b57c068f New translations activities.php (German) 2022-07-14 22:50:28 +01:00
Dan Brown
40d939394b Merge pull request #3573 from BookStackApp/m1_docker_support
M1/Apple Silicon dev docker compatibility
2022-07-14 11:03:25 +01:00
Dan Brown
7e04f70bf3 Tweaked docker dev container to work with m1 apple silicon
Tested on m1 macbook, needs testing on amd64
2022-07-14 01:34:57 +01:00
Dan Brown
4fb85a9a5c Started removal of non-view permission queries
Updated ajax search and entity selector usage to display and handle
items that the user does not have permission to interact with.
Started logic changes to not allow permission type to be passed around,
with views instead being the fixed sole permission.
2022-07-13 15:23:03 +01:00
Jan Koid
55dc86037f Fixed some typos and corrected grammar. 2022-07-12 23:05:44 +02:00
Dan Brown
2989852520 Added simple data model for faster permission generation 2022-07-12 21:13:02 +01:00
Dan Brown
4daac5a114 New translations auth.php (Croatian) 2022-07-12 21:07:49 +01:00
Dan Brown
82baab66cc New translations activities.php (Croatian) 2022-07-12 21:07:48 +01:00
Dan Brown
b0a4d3d059 Renamed and cleaned up existing permission service classes use 2022-07-12 20:15:41 +01:00
Dan Brown
943cb7810b New translations activities.php (Croatian) 2022-07-12 20:02:15 +01:00
Dan Brown
2d4f708c79 Extracted permission building out of permission service 2022-07-12 19:38:11 +01:00
Dan Brown
376640db25 New translations settings.php (Portuguese) 2022-07-12 13:03:52 +01:00
Dan Brown
9cfded1311 New translations common.php (Portuguese) 2022-07-12 13:03:51 +01:00
Dan Brown
dde2ea743f New translations auth.php (Portuguese) 2022-07-12 13:03:50 +01:00
Dan Brown
ddd45dde6b New translations entities.php (Portuguese) 2022-07-12 13:03:49 +01:00
Dan Brown
a99cbcfe12 New translations activities.php (Portuguese) 2022-07-12 13:03:47 +01:00
Dan Brown
c5e9dfa168 Optimized pre-joint-permission logic efficiency 2022-07-10 13:45:04 +01:00
mcgong
83d2a3c763 Fixed comment count update error 2022-07-06 17:30:46 +08:00
Dan Brown
f3d7d06536 New translations activities.php (Polish) 2022-07-05 19:10:04 +01:00
Dan Brown
bd1971c827 New translations editor.php (Ukrainian) 2022-07-05 14:30:15 +01:00
Dan Brown
61b3bc10a3 New translations common.php (Ukrainian) 2022-07-05 14:30:14 +01:00
Dan Brown
1a224e1719 New translations auth.php (Ukrainian) 2022-07-05 14:30:13 +01:00
Dan Brown
e89348b02a New translations entities.php (Ukrainian) 2022-07-05 14:30:12 +01:00
Dan Brown
451300606f New translations activities.php (Ukrainian) 2022-07-05 14:30:10 +01:00
Dan Brown
459659a680 New translations entities.php (Dutch) 2022-07-02 22:09:56 +01:00
Dan Brown
4487ea576f New translations common.php (Dutch) 2022-07-02 22:09:55 +01:00
Dan Brown
0c8bd581ae New translations auth.php (Dutch) 2022-07-02 21:05:42 +01:00
Dan Brown
f9f4a87e1b New translations entities.php (Dutch) 2022-07-02 21:05:41 +01:00
Dan Brown
f4fda8d80c New translations activities.php (Dutch) 2022-07-02 21:05:40 +01:00
Dan Brown
6d66682620 New translations entities.php (German) 2022-06-30 09:36:18 +01:00
Dan Brown
ab52f3367a New translations editor.php (Russian) 2022-06-29 15:26:03 +01:00
Dan Brown
5ee79d16c9 Updated version and assets for release v22.06.2 2022-06-28 11:57:37 +01:00
Dan Brown
a1ea4006e0 Merge branch 'development' into release 2022-06-28 11:57:24 +01:00
Dan Brown
a721405202 New Crowdin updates (#3540) 2022-06-28 11:56:07 +01:00
Dan Brown
d20aacb732 Merge branch '3535-group-sync-fix' into development 2022-06-28 11:47:22 +01:00
Dan Brown
65fa96e405 New Crowdin updates (#3531) 2022-06-27 14:29:10 +01:00
Dan Brown
736d6afb7d Aligned entity-selector-popup button and dblclick behaviour
Fixes #3534
2022-06-27 14:27:29 +01:00
Dan Brown
0bcd1795cb Auth group sync: Fixed unintential mapping behaviour change
Due to change in how casing was handled when used in the "External Auth
ID" role field.
Likely related to #3535.
Added test to cover.
2022-06-27 14:18:46 +01:00
Dan Brown
47887ec595 Added path example to visual theme system 2022-06-27 13:38:51 +01:00
Dan Brown
9078188939 Updated version and assets for release v22.06.1 2022-06-25 14:33:07 +01:00
Dan Brown
ed0aad1a7a Merge branch 'development' into release 2022-06-25 14:32:49 +01:00
Dan Brown
43749cd94e Merge branch 'development' of github.com:BookStackApp/BookStack into development 2022-06-25 14:27:46 +01:00
Dan Brown
107df6c28f Applied StyleCI changes 2022-06-25 14:27:32 +01:00
Dan Brown
c1d1ec5b89 New Crowdin updates (#3526) 2022-06-25 14:26:40 +01:00
Dan Brown
12c282597d Fixed non-translated category strings
For #3529
2022-06-25 14:24:38 +01:00
Dan Brown
c9d0e22132 Updated entity-selector-popup to reset on selection
Better links the core selector component to the popup version, with new
public methods for direct controlling.

For #3528
2022-06-25 14:13:17 +01:00
Dan Brown
0801955a26 Fixed grid layouts being pushed out by child content
Ran a quick app run-thorugh in FireFox & Chrome, No secondary affects
immediately noticed but possible this could cause changes elsewhere due
to wide-spread grid item child targeting.

For #3523
2022-06-25 13:55:57 +01:00
Dan Brown
3ed1ffdbeb Fixed issue blocking tags on book update
For #3527
2022-06-25 13:46:55 +01:00
Dan Brown
5c59cfb020 Updated version and assets for release v22.06 2022-06-24 11:50:56 +01:00
Dan Brown
3ca15ad68a Merge branch 'development' into release 2022-06-24 11:45:29 +01:00
Dan Brown
36f0a68f1b Added missing welsh locale to config 2022-06-24 11:42:38 +01:00
Dan Brown
ed981cbab1 New Crowdin updates (#3428) 2022-06-24 11:35:59 +01:00
Dan Brown
f69af8933c Updated translator list before v22.06 release 2022-06-24 11:30:15 +01:00
Dan Brown
46d71a181e Updated php deps and applied styleci changes 2022-06-22 12:49:58 +01:00
Dan Brown
8d8da31fdd Added base template convenience partials for theme system users
Included test to cover usage and paths.
Closes #894
2022-06-22 12:47:31 +01:00
Dan Brown
0d9b5a9d90 Merge branch 'login-auto-redirect' into development 2022-06-21 15:38:01 +01:00
Dan Brown
8b211ed461 Review and update of login auto initiation PR
For PR #3406

- Updated naming from 'redirect' to 'initate/initation'.
- Updated phpunit.xml and .env.example.complete files with the new
  option.
- Cleaned up controller logic a bit.
- Added content and design to the new initation view to not leave user
  on a blank view for a while.
- Added non-JS button to initiation view as fallback option for
  progression.
- Moved new test to it's own Test class and expanded with additional
  scenario tests for better functionality coverage.
2022-06-21 15:32:18 +01:00
Dan Brown
9dd69b04b8 Fixed code snippets being added as single line
TinyMCE was adding attributes to <br> elements within code blocks which
would then not be converted to newlines by our code regex match.
This changes the conversion to use dom querying instead.

Fixes #3507
2022-06-21 12:01:06 +01:00
Dan Brown
0c6f598d91 Fixed issue where text after line breaks not indexed
Linebreaks would previously essentially be removed during index and
hence joined to adjacent words, breaking prefix matching.
Added test to cover.
For #3508
2022-06-20 23:47:42 +01:00
Dan Brown
df94b73e29 Merge pull request #3512 from BookStackApp/code_manager_updates
WYSIWYG Code Editor Updates
2022-06-20 23:13:28 +01:00
Dan Brown
7d4b941abf Added code editor changes mobile design handling 2022-06-20 23:12:07 +01:00
Dan Brown
d181106df3 Adjusted code manager changes for dark mode 2022-06-20 23:06:54 +01:00
Dan Brown
75110813e6 Aligned other popup windows
Primary change was altering image-manager to use same footer bar style
as other windows.
2022-06-20 23:02:06 +01:00
Dan Brown
1e41546e51 Updated code editor language lists
To align and update supported languages.

Related to #3511 and #3494
2022-06-20 17:49:56 +01:00
Dan Brown
f39b565a1c Tweaked code editor sidebar side to be smaller 2022-06-20 17:16:28 +01:00
Dan Brown
77cd550fae Polished up code editor design 2022-06-20 17:11:34 +01:00
Dan Brown
96d9077479 Started design changes to the code-editor 2022-06-20 13:42:12 +01:00
Dan Brown
be1d691529 Merge pull request #3499 from BookStackApp/convert_hierachy
Chapter and Book Conversion Actions
2022-06-20 12:51:13 +01:00
Dan Brown
8cde362f6f Removed bad trailing comma in method 2022-06-19 18:45:48 +01:00
Dan Brown
388343aeb0 Fixed failing tests after conversion changes 2022-06-19 18:44:34 +01:00
Dan Brown
ba25dda031 Applied styleci changes for conversion work 2022-06-19 18:14:53 +01:00
Dan Brown
85f59b5275 Added tests for content conversion action permissions
- Updated 'removePermissionFromUser' test helper to work for
  entity-permissions that become part of the joint permissions system.
2022-06-19 18:12:36 +01:00
Dan Brown
65d4505079 Added tests and doc updates for shelf/book cover image API abilities 2022-06-19 17:26:23 +01:00
Dan Brown
663f81a2b1 Added tests to cover convert functionality
Also updated cloner class with typed properties.
2022-06-19 16:57:33 +01:00
Dan Brown
f145ffc930 Extracted conversion text to translation file 2022-06-19 16:23:18 +01:00
Dan Brown
19d7e26dda Merge pull request #3503 from andrii-bodnar/fix/crowdin-name
Fix Crowdin name in the language_request issue template
2022-06-16 12:07:40 +01:00
Andrii Bodnar
a13b9d8d14 Fix Crowdin name in the language_request issue template 2022-06-16 11:34:27 +03:00
Dan Brown
8c67011a1d Got book to shelf conversions working
- Also extracted shelf to book view elements to own partial.
- Fixed some existing logic including image param handling in update
  request and activity logging against correct element.
2022-06-15 15:05:08 +01:00
Dan Brown
8da856bac3 Got chapter conversion to books working
- Added required UI within edit view.
- Added required routes and controller actions.
2022-06-14 16:42:29 +01:00
Dan Brown
90ec40691a Added clone of entity permissions on chapter/book promotion 2022-06-14 15:55:44 +01:00
Dan Brown
d676e1e824 Started work on hierachy conversion actions
- Updates book/shelf cover image handling for easier cloning/handling.
- Adds core logic for promoting books/chapters up a level.
- Enables usage of book/shelf cover image via API.

Related to #1087
2022-06-13 17:20:21 +01:00
Dan Brown
0a05119aa5 Applied styleci changes, updated composer deps 2022-06-10 12:37:14 +01:00
Dan Brown
abc283fc64 Extracted download response logic to its own class
Cleans up base controller and groups up download & streaming logic for
potential future easier addition of range request support.
2022-06-08 23:50:42 +01:00
Dan Brown
e72ade727d Added audio mimes to our safe list for inline serving
Closes #3485
2022-06-08 22:30:55 +01:00
Dan Brown
c8b123bfac Updated composer deps, applied styleci changes 2022-06-08 18:00:30 +01:00
Dan Brown
88012449f3 Reorganised and split out export templates & styles
Moved export templates elements into their own folder for better
grouping of logical usage.
Within the base export template, added some body classes to allow easier
targeted customisation via custom head css.
Split content of export templates into smaller partials for easier
future customization.

Closes #3443
2022-06-08 17:56:59 +01:00
Dan Brown
e00d88f45d Updated markdown preview to update on diff-basis
Uses vdom system to diff and update the current markdown preview view
instead of requiring a full HTML replace change.
This should provide better performance, expecially where dynamically
loaded content such as iframes were in use.

Closes #3454
2022-06-07 16:07:28 +01:00
Dan Brown
3fe666f36a Updated image drop handling to respect original file name
Now uses the previously timestamp gen name as a backup to the original
name. Aligns with the image manager upload which uses the original name
where given.

Closes #3470
2022-06-07 14:59:00 +01:00
Dan Brown
3f271ebecb Removed image_id property from books & shelves api docs
This was either not provided or not provided for the last 18 months.
Likely not providing much value as-is so removing.

Closes #3474
2022-06-07 14:30:43 +01:00
Dan Brown
7c597a05f6 Added codeblock latex/stext support
For #3458
2022-05-30 18:41:40 +01:00
Dan Brown
16e023985d Prevented inadvertant logging during MFA flow
- Added StoppedAuthenticationException to dontReport list.
- Added test to cover.

Closes #3468
2022-05-30 18:31:08 +01:00
Dan Brown
43cbab2822 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2022-05-30 17:01:46 +01:00
Dan Brown
1a3505c899 Updated JS deps 2022-05-30 17:01:32 +01:00
Dan Brown
2930025f51 Update dev version to track current release target 2022-05-30 16:58:01 +01:00
Dan Brown
39fcf3a68f Merge pull request #3416 from BookStackApp/group_sync_comma_escaping
Added ability to escape role "External Auth ID" commas
2022-05-30 16:55:32 +01:00
Dan Brown
6ce34fe6cc Merge pull request #3433 from BookStackApp/tiny_improvements
Bunch of tiny improvements
2022-05-30 16:51:59 +01:00
Dan Brown
3c3aed58aa Updated funding with kofi link 2022-05-30 16:49:24 +01:00
Dan Brown
73f36b279e Updated PHP deps 2022-05-30 16:46:48 +01:00
Dan Brown
2b817e7d24 Updated attachment links to have dropdown for open type
- Allows easier accessibility of inline attachments.
- Introduces a new split-icon-list-item thingy to support such cases
  where only part of the button is actually linked.
2022-05-19 17:38:04 +01:00
Dan Brown
cb10ad804f Made chapter toggle in book sidebar nav more consistent
- Now has a hover state to match other items.
- Now spans the full sidebar with like other items.
- Also updated chapter-toggle to a chapter-contents component, following
  the newer component system.
2022-05-18 14:06:40 +01:00
Dan Brown
eeccc2ef10 Readjusted book child item styles after other changes
Was extra space showing due to structure changes and flex gap.
2022-05-18 13:28:34 +01:00
Dan Brown
b030c1398b Tweaked chapter list item styles
- Improves animation smoothness
- Changed animation slideup/down animations to use max-height instead of height
  to better avoid jutter at the end.
- Cleaned spacing to match page items in books listing.
2022-05-18 13:18:21 +01:00
Dan Brown
4759fa1e1f Made the "Custom HTML Head Content" setting a highlighted code editor 2022-05-17 17:39:31 +01:00
Dan Brown
cb1c2db282 Aligned collapsed header dropdown item styles
Previously the desktop-visible items would style different when collapsed
into the expanded dropdown menu, compared to existing items.
2022-05-17 14:27:58 +01:00
Dan Brown
4866a3a198 Refined header bar styles
- Updated many items to be flexbox-based.
- Updated & aligned hover states across header bar items.
2022-05-17 14:16:43 +01:00
Dan Brown
340c9ec7a1 Fixed some inputs affected by height changes 2022-05-17 13:37:43 +01:00
Dan Brown
49498cfaf9 Fixed entity-specific tag counts listing
Was reporting wrong due to use of old polymorphic namespace references.
Test was not picking up as assertElementContains had wider scope than
expected, looking within the HTML of the element instead of the text
which you might expect. Updated test helper to look at text instead.
2022-05-16 14:05:21 +01:00
Dan Brown
3a4aa81115 Removed dialog debug script from default home
Accidentally left in from before.
Closes #3430
2022-05-16 13:36:42 +01:00
Dan Brown
d20c74babf Improved input size consistency
Specifically updates dropdown search and user-search implementation,
although does affect all inputs.
Decouples breadcrum and select-style dropdown search toggles.

Addresses #2678
2022-05-14 16:05:29 +01:00
Dan Brown
9fda0df798 Updated dropdown search boxe positions to align with other dropdowns 2022-05-14 14:19:54 +01:00
Dan Brown
6fa699a835 Fixed skip-to-content link shadow being slightly visible
Would cause a slight dark area in top left of view while hidden.
2022-05-14 13:59:10 +01:00
Dan Brown
78920d7d65 Updated tri-layout sidebars to not be cut-off by padding
Would cause effect where scroll area would be cut of by spacing which
looked a bit strange. This retains the same padding sizes but cuts the
content at the header or top of viewport.
2022-05-14 13:55:03 +01:00
Dan Brown
35a47a273b Added animation transition for breadcrumb dropdown load
Animates the height on breadcrumb dropdown menus to transition to the
loaded animations quicker. Includes a new animation helper for doing
similar tasks in future.
2022-05-14 13:32:25 +01:00
Dan Brown
89dfa43e73 Fixed loading animation delay
Loading animation would show in an unready state due to animation-delay
on components. Updated to a negative delay to ensure elements were in
correct positions right away upon show.
2022-05-14 13:31:24 +01:00
Dan Brown
2c74dfd1d4 Updated breadcrumb dropdown styles, improved keyboard nav
- Removed harsh theme color border between search and content.
- Prevented intermediate focus on list container to align arrow & tab
  behaviour, and to get to content quicker.
2022-05-14 13:11:48 +01:00
Dan Brown
e6864a9cff Improved card list design
- Removed border and rounded list item styles to make hover states have
  less edge detail and to align with other UI elements.
- In expanded-detail view, removed space used for entity description if
  there is not description content existing.
2022-05-14 12:54:23 +01:00
Dan Brown
60e319c4b4 Tidied up book navigation styles
- Removed background track line since it would darken entity item bars.
- Updated item spacing to be a bit tighter.
- Updated action hover styles to be a bit lighter, and visible on dark
  mode, to fit rest of system.
2022-05-13 18:34:47 +01:00
Dan Brown
24b31b624c Cleaned up entity details listing 2022-05-13 18:03:43 +01:00
Dan Brown
a0fe6147d8 Improved the display of dropdown menus
- Tweaked styling to add a little extra shadow and be more rounded to
  match other UI areas.
- Added slight horizontal inset when in right sidebar to prevent shadow
  being cut-off in most cases.
- Added logic to "drop upwards" if dropping down would take the menu
  offscreen.
2022-05-13 17:12:45 +01:00
Dan Brown
221d910ff2 Reduced excess margin in chapter contents lists 2022-05-12 17:27:57 +01:00
Dan Brown
bef2045df1 Embedded css sources for easier firefox dev work 2022-05-12 17:27:29 +01:00
Dan Brown
f021823287 Updated default value for secure session detection
Updated default value for APP_URL so that the startsWith call is not
passed null, since that causes deprecation notice in PHP8.1.
Would show when APP_URL was not set, adding extra confusiion.
2022-05-11 16:47:09 +01:00
Dan Brown
60014989f5 Updated version and assets for release v22.04.2 2022-05-09 16:10:16 +01:00
Dan Brown
57b10f195e Merge branch 'development' into release 2022-05-09 16:09:54 +01:00
Dan Brown
3a8a476906 Updated translators, applied styleCI change 2022-05-09 16:09:31 +01:00
Dan Brown
328bc88f02 Fixed LDAP_DUMP_* options when data contains binary
Dumping details that were binary, such as the jpegphoto data, would
cause the dump to fail on the encoding to JSON.
This change forces content to be UTF8 before dumping.
Updated existing test to cover.

Closes #3396
2022-05-09 15:57:50 +01:00
Dan Brown
2a99e23e6d Updated attachment download to check OB before cleaning it
Call to `ob_end_clean` would error if the environment did not use the
PHP `output_buffering` option. This adds an additional check and updates
the comment to be more specific to the exact scenario of the condition.
Tested with output_buffering=Off and output_buffering=4096

Closes #3415
2022-05-09 15:25:06 +01:00
Dan Brown
b855bbaaea New Crowdin updates (#3418) 2022-05-09 15:15:35 +01:00
Dan Brown
96436839f1 Added rate limit section to the API docs
Closes #3423
2022-05-09 15:12:29 +01:00
Dan Brown
b4f29a85ab Added Farsi language available
Closes #3426
2022-05-09 14:58:04 +01:00
Dan Brown
4a2a044f3d Updated PHP deps 2022-05-09 14:57:34 +01:00
Dan Brown
ca09ed916f Added support plans link to issue links 2022-05-05 15:48:27 +01:00
Dan Brown
dbefda055f Updated method of string interpolation
In prep for future PHP changes as per RFC
https://wiki.php.net/rfc/deprecate_dollar_brace_string_interpolation
2022-05-05 09:33:25 +01:00
Dan Brown
b1e95eb39f Updated version and assets for release v22.04.1 2022-05-04 21:26:58 +01:00
Dan Brown
b3da77b8f9 Merge branch 'development' into release 2022-05-04 21:26:31 +01:00
Dan Brown
93ef8c97b6 Applied styleci changes 2022-05-04 21:19:46 +01:00
Dan Brown
420b29f32f New Crowdin updates (#3402) 2022-05-04 21:18:47 +01:00
Dan Brown
d795af04df Added ability to escape role "External Auth ID" commas
- Using a backslash in this field before a comma.
- Could potentially (Although unlikely) be a breaking change.

For #3405
2022-05-04 21:03:13 +01:00
Dan Brown
d2ed98d20d Merge branch 'development' of github.com:BookStackApp/BookStack into development 2022-05-04 21:01:20 +01:00
Dan Brown
ebc69a8f2c Fixed double path slash URL issue in some cases
- Occurred on system request path usage (Primarily on guest login
  redirection) when a custom path was not in use.
- Added test to cover.

For #3404
2022-05-04 20:08:22 +01:00
Robert Meredith
d5ce6b680c Skip intermediate login page with single provider 2022-05-02 20:35:11 +10:00
Dan Brown
1a345b74bb Updated version and assets for release v22.04 2022-04-29 15:55:32 +01:00
Dan Brown
8ffc3a4abf Merge branch 'development' into release 2022-04-29 15:55:05 +01:00
Dan Brown
44013721f0 New Crowdin updates (#3401) 2022-04-29 15:53:06 +01:00
Dan Brown
16222de5fa Added uzbeck into local list
Not yet an actual added language yet due to low translation rate.
2022-04-29 15:52:11 +01:00
Dan Brown
ebfe946160 Updated translation attribution before v22.04 2022-04-29 15:43:30 +01:00
Dan Brown
5d2aad6a9e Merge pull request #3373 from evandroamaro/patch-1
Tiny header
2022-04-29 15:41:04 +01:00
Dan Brown
8fb016d1bf New Crowdin updates (#3384) 2022-04-29 15:40:38 +01:00
Dan Brown
c216a6a210 Applied stylci changes, updated composer deps 2022-04-29 15:38:06 +01:00
Dan Brown
26af9acc6c Improved iframe & summary handling in HTML to MD conversion 2022-04-29 14:58:28 +01:00
Dan Brown
c8a7acb6c7 Fixed drawing handling on HTML to Markdown conversion 2022-04-29 12:17:14 +01:00
Dan Brown
d3b39fbe50 Move html to markdown formatting tests to their own class 2022-04-29 11:50:34 +01:00
Dan Brown
ac7b2dd1bf Tweaked DRAW.IO params in complete .env file to show configure param 2022-04-27 17:52:35 +01:00
Dan Brown
f1a8ad4980 Applied latest StyleCI changes 2022-04-25 18:42:31 +01:00
Dan Brown
d5b7fff102 Merge branch 'recycle_bin_api_endpoints' into development 2022-04-25 18:32:55 +01:00
Dan Brown
0930e8519c Updated polymorphic database relation types to simpler version
- Means we can use these simpler types in API response, As desired in #3377.

Closes #3395
2022-04-25 18:31:37 +01:00
Dan Brown
ff8dadefee Reviewed recycle bin API PR and made changes
Made the following changes, many of these are just to align with
existing conventions.

- Updated urls to be hypenated, instead of underscored, to match other system endpoints.
- Updated URL parameter to be `deletionId` instead of `id`, and removed the ID-based comment on controller methods, so the required ID model is clear from the URL alone, since its not clear from the URL endpoint alone like existing endpoints. This follows the pattern used in the "web" routes.
- Added extra detail on some controller method comments, and copied permission comment to each method.
- Removed existing field visibility mechanisms to use simpler model-based visibility since we didn't need anything too special here (After some of my other changes).
- Allowed the "deletable" model to be shown in response to provide a little more detail on the main deleted item.
- Updated parent/child-count loading to be on the "deletable" model instead of additional properties which results in simpler controller logic and enforces the idea these are relations on the deletable, not the deletion itself. It also removes additional exposure of model namespacing.
- Updated (int) casts to intval, just since that's our most common conversion method in the codebase.
- Testing: Removed `actingAsAuthorizedUser` and used the admin user instead to prevent extra auth steps on each test.
- Testing: Cut logic/data-checks from tests if already covered by other tests.
- Testing: Added simple assertions for delete/restore response data.
- Examples: Updated list example to reflect changes.

Review of PR #3377
To be followed up with changes to polymorphic relations to hide
namespacing.
2022-04-25 17:54:59 +01:00
Dan Brown
2b0ae23da0 Updated composer deps, applied latest StyleCI changes 2022-04-24 18:22:40 +01:00
Dan Brown
63cb6015a8 Merge pull request #3364 from BookStackApp/app_url_requests
Updated custom request overrides to better match original intent
2022-04-24 14:52:38 +01:00
Dan Brown
5a7fb20116 Merge pull request #3387 from BookStackApp/editor_switching
Page editor switching
2022-04-24 14:03:03 +01:00
Dan Brown
829f808800 Merge pull request #3365 from BookStackApp/data_streaming
Add data streaming where beneficial to reduce memory usage
2022-04-24 13:59:47 +01:00
Dan Brown
0dfe5cb66b Merge pull request #3391 from BookStackApp/drawio_config_event
Made it possible to configure draw.io/diagrams.net integration
2022-04-24 13:58:59 +01:00
julesdevops
14bccae6bd do some cleanup and add doc 2022-04-24 10:49:29 +02:00
Dan Brown
b97c150ac8 Added additional testing for editor switching permissions 2022-04-23 23:34:15 +01:00
Dan Brown
0c5723d76e Switched to database-based tracking for page editor
- Works better to avoid bad assumptions when showing the editor based
  upon content type.
- Also updated some previous tests to cleaner format.
2022-04-23 23:20:46 +01:00
Dan Brown
bec61a56c0 Added listing of editor type to revisions
- Also tweaked some editor revision table styles and merged some
  sections to reduce space usage.
2022-04-23 15:03:58 +01:00
Dan Brown
1b46aa8756 Aded tests for core editor switching functionality 2022-04-23 14:22:04 +01:00
julesdevops
f14e6e8f2d Complete list endpoint and add some tests 2022-04-21 22:23:24 +02:00
Dan Brown
0003ce61cd Fixed failing test after drawio default url change 2022-04-20 23:42:47 +01:00
Dan Brown
d76bbb2954 Made it possible to configure draw.io/diagrams.net integration
Added new editor public event to hook into draw.io configuration step.
Required change of embed url to trigger the configure step.
2022-04-20 23:32:02 +01:00
Dan Brown
478067483f Linked up confirmation prompt to editor switching 2022-04-20 18:21:21 +01:00
Dan Brown
eff539f89b Added new confirm-dialog component, both view and logic 2022-04-20 14:58:37 +01:00
Dan Brown
214992650d Standardised dropdown list item styles, Extracted page editor toolbar
- Updated all dropdown list item actions into three specific styles:
  icon-item, text-item & label-item. Allows a stronger structure while
  prevents mixing of styles as we were getting for header dropdown in
  dark mode.
- Extracted out page editor top toolbar to its own view file & split
  editor switch options to different markdown options.
2022-04-20 14:03:47 +01:00
Dan Brown
492ffff0a4 Added core editor switching functionality 2022-04-18 17:39:28 +01:00
Dan Brown
956eb1308f Aligned page edit controller method data usage
Extracted page editor view data gathering to its own class for
alignment. Updated the data used in views as part of the process to use
view-specific variables instead of custom attributes added to models.
Also moved tinymce library loading so it's not loaded when not using the
wysiwyg editor.
2022-04-17 23:01:14 +01:00
Dan Brown
0cc215f8c3 Added editor type change button 2022-04-17 15:01:29 +01:00
Dan Brown
e8e38f1f7b Added an 'editor-change' role permission 2022-04-17 14:33:06 +01:00
Dan Brown
7dc80a9e14 Updated editor setting to reflect "Default editor" 2022-04-17 14:13:14 +01:00
Dan Brown
e49afdbd72 New Crowdin updates (#3358) 2022-04-14 16:14:05 +01:00
Dan Brown
56254bdb66 Added testing for our request method overrides 2022-04-13 13:02:42 +01:00
Dan Brown
25654b2322 Fixed base URL starting slash usage 2022-04-13 12:46:19 +01:00
Dan Brown
27339079f7 Extracted esbuild config to a build script
Allows us to use NodeJS code for file/directory locating to not be
shell/os specific, while also also reducing duplicated complexity within
packages.json file.

Related to #3323
2022-04-13 12:08:56 +01:00
julesdevops
55e52e45fb Start recycle bin API endpoints: list, restore, delete 2022-04-07 22:34:00 +02:00
evandroamaro
c979e6465e Tiny header
Had the same translation as the small header. Corrected the translation.
2022-04-05 10:53:52 +01:00
Dan Brown
c30a9d3564 Touched entity timestamps on entity tag update
Decided it's relevant to entity updated_at since tags are now indexed
alongside content.

- Also fixed tags not applied on shelf.
- Also enforced proper page API update validation.
- Adds tests to cover.

For #3319
Fixes #3370
2022-04-04 17:24:05 +01:00
Dan Brown
59d1fb2d10 Fixed tests from streaming changes
- Added testing check to buffer stop/clear on streaming output due to
  interference during tests.
- Made content-disposition header a little safer in download responses.
- Also aligned how we check for testing environment.
2022-04-03 16:22:31 +01:00
Dan Brown
08a8c0070e Added streaming support to API attachment read responses
Required some special handling due to the content being base64-encoded
within a JSON response.
2022-04-02 19:21:19 +01:00
Dan Brown
cb770c534d Added streamed uploads for attachments 2022-04-02 18:46:48 +01:00
Dan Brown
6749faa89a Fixed streamed outputs in more extreme scenarios
Fixes hitting memory limits where downloaded file sizes are much greater
than memory limit. Stopping and flushing output buffer seemed to stop
limits causing issues when fpassthru is used.
Tested with 24M memory limit and 734M file
2022-04-02 18:42:15 +01:00
Dan Brown
82e8b1577e Updated attachment download responses to stream from filesystem
This allows download of attachments that are larger than current memory
limits, since we're not loading the entire file into memory any more.

For inline file responses, we take a 1kb portion of the file to sniff
before to check mime before we proceed.
2022-04-02 18:07:43 +01:00
Dan Brown
4dce03c0d3 Updated custom request overrides to better match original intent
This updates the custom Request handler to provide only the scheme and
host on the `getSchemeAndHttpHost` call, instead of providing the whole
APP_URL value, while adding an override to the 'getBaseUrl' to use the
APP_URL content instead of the guessed/detected Symfony value.

Untested apart from simple local setup.

Related to #2765
2022-04-02 17:14:37 +01:00
Dan Brown
7233c1c7b2 Updated version and assets for release v22.03.1 2022-03-30 19:37:07 +01:00
Dan Brown
1309a01131 Merge branch 'development' into release 2022-03-30 19:36:45 +01:00
Dan Brown
affae2e3c4 New Crowdin updates (#3354) 2022-03-30 19:29:13 +01:00
Dan Brown
1a90b98b8f Updated composer dependancies 2022-03-30 19:22:47 +01:00
Dan Brown
da4308bb0f Fixed settings redirect issue and custom head display
- Fixed issue where redirect for `/settings` view would not be ran
  through base url generator so would not create a correct path in some
  cases. Now routed through controller with normal redirect.
- Fixed custom head content being active on settings pages due to route
  name changes, for when viewing settings, in last release.

Fixes #3356 and #3355
2022-03-30 19:15:24 +01:00
Dan Brown
0333185b6d Updated version and assets for release v22.03 2022-03-30 13:49:17 +01:00
Dan Brown
83f89f64e8 Merge branch 'development' into release 2022-03-30 13:49:05 +01:00
Dan Brown
135022136a New Crowdin updates (#3353) 2022-03-30 13:31:59 +01:00
Dan Brown
12f96bb1a4 Updated translation contributors, added Basque to language options 2022-03-30 13:12:17 +01:00
Dan Brown
678314a0c5 New Crowdin updates (#3320) 2022-03-30 13:00:27 +01:00
Dan Brown
0887c39694 Updated example env with LDAP group dump option 2022-03-29 11:49:02 +01:00
Dan Brown
078e8e7dc3 PHPStan and StyleCI fixes
- Updated PhpStan PHP version option to match project.
- Applied StyleCI changes.
- Updated static to self in WebhookFormatter, following static analysis
  guidance.
- Fixed mis-matched header tags.
2022-03-28 11:31:06 +01:00
Dan Brown
038015f852 Merge pull request #3349 from BookStackApp/settings_reorg
Reorganization of settings view
2022-03-28 11:22:21 +01:00
Dan Brown
7c12920dc8 Added 404 response for non-existing setting categories
- Added test to cover.
2022-03-28 11:16:20 +01:00
Dan Brown
895f656897 Split out settings view and made functional
- Split settings out to new views using a core shared layout.
- Extracted added language text to translation files.
- Updated settings routes to be dynamic to category.
- Added redirect for old primary settings route.
- Updated existing tests to cover settings route changes.
- Added tests to cover settings view.
- Improved contrast of settings links for dark mode.
2022-03-28 11:09:55 +01:00
Dan Brown
31dbf132b9 Started playing with new settings view layout 2022-03-26 21:36:05 +00:00
Dan Brown
b5281bc9ca Fixed tests, applied StyleCI changes 2022-03-26 20:38:03 +00:00
Dan Brown
3625f12abe Added extendable/scalable formatter for webhook data
Creates a new organsied formatting system for webhook data, with
interfaces for extending with custom model formatting rules.
Allows easy usage & extension of the default bookstack formatting
behaviour when customizing webhook events via theme system, and keeps
default data customizations organised.

This also makes the following webhook data changes:
- owned_by/created_by/updated_by user details are loaded for events with
  Entity details. (POTENTIALLY BREAKING CHANGE).
- current_revision details are loaded for page update/create events.

Added testing to cover added model formatting rules.

For #3279 and #3218
2022-03-26 16:53:02 +00:00
Dan Brown
55d61fceb2 Added manual image thumbnail exif orientation handling
Uses original image data to extract orientation exif to apply image
transformations before scaling and save. Manually done due to issues
with exif data loss during the existing Invervention image path.

For #1854
2022-03-26 12:32:08 +00:00
Dan Brown
2325a307a5 Applied latest styleCI changes 2022-03-25 11:14:27 +00:00
Dan Brown
d2b49084b0 Added pre-render sizes to wysiwyg code blocks
Sets sizes on WYSIWYG code block sections based on content lines
as an early pre-codemirror height prediction to avoid excessive
jumping in the editor.

For #3326
2022-03-25 11:13:04 +00:00
Dan Brown
8594f42584 Added LDAP group debugging env option
Closes #3345
2022-03-23 16:34:23 +00:00
Dan Brown
dd7463259a Added wysiwyg filter to handle <br> tags within code blocks
This filters out <br> elements within code blocks and replaces them with
newlines. The editor started using <br>'s more harshley after some
configuration changes upon upgrading tinymce, in which we standardised
on forced br tags to avoid empty elements.

For #3327
2022-03-23 15:11:14 +00:00
Dan Brown
d23b24b8db Added additional missing editor translations
- Also merged StyleCI fixes

As per #3342
2022-03-23 14:41:54 +00:00
Dan Brown
1c859e94e0 Fixed conctenation of direct book pages within markdown export
- Updated to ensure seperation with newlines.
- Added test to cover.

For #3341
2022-03-23 14:31:42 +00:00
Dan Brown
981807220c Applied StyleCI changes and updated dependancies 2022-03-23 12:02:01 +00:00
Dan Brown
a2231c3604 Merge pull request #3333 from BookStackApp/wysiwyg_tasklist
WYSIWYG tasklist support
2022-03-23 11:58:16 +00:00
Dan Brown
622adc5450 Updated justify translation for editor
Fixes #3342
2022-03-23 11:57:20 +00:00
Dan Brown
95e496d16f Added translation string for tasklist WYSIWYG action 2022-03-23 11:54:27 +00:00
Dan Brown
883e18f7c4 Updated tasklist style and functionality for cross-browser use
- Updated styles to better align checkboxes within page content.
- Updated functionality to use a cross-compatible property on checkbox
  click within the editor.
2022-03-23 11:51:19 +00:00
Dan Brown
c5aad29c72 Added tasklist support to markdown exporter 2022-03-22 14:56:51 +00:00
Dan Brown
ea62fe6004 Improved tasklist wysiwyg behaviour
- Updated buttons/actions to better handle nesting.
- Added hack for better usage with normal bullets
2022-03-22 14:03:20 +00:00
Dan Brown
5ae9ed1e22 Added functioning wysiwyg tasklist toolbar button
- Includes new icon.
- Includes menu button overrides of existing list styles to prevent
  incompatible mixing.
2022-03-20 13:30:48 +00:00
Dan Brown
b6be8a2bb9 Added WYSIWYG tasklist clicking ability 2022-03-20 11:59:46 +00:00
Dan Brown
65dd7ad1e9 Changed to a psuedo-style approach for tasklist in wysiwyg 2022-03-19 17:13:26 +00:00
Dan Brown
f991948c49 Started initial tasklist attempt, failed implementation 2022-03-19 16:04:33 +00:00
Dan Brown
ee6a2339b6 Applied latest styleCI changes 2022-03-09 14:30:36 +00:00
Dan Brown
fd26f54b99 Merge pull request #3298 from BookStackApp/wysiwyg_links
WYSIWYG editor link updates
2022-03-09 14:29:03 +00:00
Dan Brown
11a1a6fb16 Updated version and assets for release v22.02.3 2022-03-07 15:12:22 +00:00
Dan Brown
882c609296 Merge branch 'development' into release 2022-03-07 15:12:09 +00:00
Dan Brown
77ad819970 Updated translation attribution before v22.02.3 release 2022-03-07 15:06:44 +00:00
Dan Brown
2835e5be93 New Crowdin updates (#3312) 2022-03-07 15:06:21 +00:00
Dan Brown
856fca8289 Updated CSP with frame-src rules
- Configurable via 'ALLOWED_IFRAME_SOURCES' .env option.
- Also updated how CSP rules are set, with a single header being used
  instead of many.
- Also applied CSP rules to HTML export outputs.
- Updated tests to cover.

For #3314
2022-03-07 14:27:41 +00:00
Dan Brown
48d0095aa2 Added mysql-ssl-ca option to complete .env 2022-03-02 21:51:18 +00:00
Dan Brown
176a0dcd59 Updated version and assets for release v22.02.2 2022-03-01 22:45:41 +00:00
Dan Brown
94b0f70bfa Merge branch 'development' into release 2022-03-01 22:45:12 +00:00
Dan Brown
36d7ff77a9 New translations editor.php (Italian) (#3301) 2022-03-01 22:32:43 +00:00
Dan Brown
fb16ac326f Reduced dynamic fade in dark mode
For #3203
2022-03-01 22:29:31 +00:00
Dan Brown
5947f59a04 Updated strategy for empty newline sections
- For some reason, TinyMCE would handle empty paragraphs with a '&nbsp'
  by default but this would be removed when the paragraph had an
  attribute. This was fine in the old editor.
- This changes the approach to use '<br>' tags within elements
  for "spaced emptiness".
- For compatbility with any existing empty paragraphs, I updated the
  styles to show default height for empty paragraph sections.
- This also makes changes to help preserve encoded &nbsp; html tags
  since they were getting converted along the journey.

Related to #3302
2022-03-01 17:26:06 +00:00
Dan Brown
1843d80fb7 Added cache breaker to tinymce loading systems
Takes the version from BookStack app.js paths instead of tinyMCE version
since things external from TinyMCE could be loaded using this.
2022-03-01 13:41:53 +00:00
Dan Brown
6252b46395 Added a custom link context toolbar
- Allows for easy unlinking, link preview or link editing.
- Created custom one to limit actions available.
- Performed refactoring of non-plugin toolbar editor code to extact into
  its own file.

Related to #3276
2022-02-28 13:56:23 +00:00
Dan Brown
20ecaa5c5a Added ctrl+shift+k shortcut to WYSIWYG
Shows entity select dialog for more direct entity link insertion.
Aligns with shortcut from markdown editor.

For #3244
2022-02-28 13:34:32 +00:00
Dan Brown
08b2a77d41 Updated version and assets for release v22.02.1 2022-02-27 17:46:06 +00:00
Dan Brown
3e8e9a23cf Merge branch 'development' into release 2022-02-27 17:45:49 +00:00
Dan Brown
1253711c7d New translations editor.php (Chinese Simplified) (#3291) 2022-02-27 17:44:58 +00:00
Dan Brown
963d8f4693 Updated issue templates, readme and dev version
- Updated bug report template to capture browser.
- Updated readme roadmap.
- Bumped dev version.
2022-02-27 17:26:27 +00:00
Dan Brown
0de4d6d223 Improved WYSIWYG code block behaviour via range of fixes
- Fixed issues with new code blocks breaking or acting odd due to
  misnamed contenteditable attribute.
- Helped fix issue where code blocks may show in a strage blank state
  due to timing within shadow dom loading.
- Fixed some function timing issues where some functions required their
  async predecessor to have finished.

Tested rather heavily in firefox and brave.
Fixes #3292
2022-02-27 17:21:24 +00:00
Dan Brown
06f694bad2 Updated tinymce link query to break caches
Fixes #3293
2022-02-27 16:03:18 +00:00
Dan Brown
58b83b64c8 Updated version and assets for release v22.02 2022-02-26 12:01:44 +00:00
Dan Brown
dfe4cde6ee Merge branch 'development' into release 2022-02-26 12:00:46 +00:00
Dan Brown
41689a1e65 New Crowdin updates (#3259) 2022-02-26 11:46:33 +00:00
Dan Brown
2ae8026903 Updated translators for v22.02 release 2022-02-26 11:40:09 +00:00
Dan Brown
dcb36b27a0 Updated github issue templates
- Removed titles since they don't provide added benefit upon the labels
  and would often lead to being submitted with just the placeholder
  title.
- Feature request form
  - Added further context to benefits field for hopefully better
    responses that target the core goal.
  - Added a field to ask if feature can already be achieved, to
    gain an idea if the submitter has explored other options (if
    existing).
  - Added a field to ensure the submitter has search the issue list
    before submitting.
  - Added a field to ask existing BookStack usage time to understand
    potential evolution of usage and/or influence of other platforms.
2022-02-24 18:26:34 +00:00
Dan Brown
83082c32ef Applied latest StyleCI changes 2022-02-24 15:04:09 +00:00
Dan Brown
1e112f78d8 On WYSIWYG details unwrap, provided better restore of cursor
Also prevents the toolbar from sticking around after the details block
was removed.
2022-02-24 15:02:23 +00:00
Dan Brown
9283f28e31 Updated JS deps 2022-02-24 15:02:06 +00:00
Dan Brown
7f5fc9fbe3 Updated composer dependancies
Includes update to dompdf v1.2 which helps address image sizing in
tables and hence fix #3190
2022-02-24 14:30:55 +00:00
Dan Brown
ce566bea2a Updated OIDC error handling for better error reporting
Fixes issue where certain errors would not show to the user
due to extra navigation jumps which lost the error message
in the process.
This simplifies and aligns exceptions with more directly
handled exception usage at the controller level.

Fixes #3264
2022-02-24 14:16:09 +00:00
Dan Brown
63ce3c9add Updated incorrect feature request template description 2022-02-13 13:18:42 +00:00
Dan Brown
f0470afb4c Applied StyleCI changes, updated readme badges & roadmap 2022-02-13 13:16:43 +00:00
Dan Brown
f8e6172582 Updated github actions to ignore language branch
Old branch filters did not seem to work since they are supposed to
reference the target branch, not source branch.
Instead used if statement to prevent run on crowdin branch.
2022-02-13 13:03:41 +00:00
Dan Brown
7a8505f812 Made a pass to clean up UserRepo 2022-02-13 12:56:26 +00:00
Dan Brown
9806907d53 Merge pull request #3260 from BookStackApp/wysiwyg_details
WYSIWYG details/summary blocks
2022-02-09 19:33:53 +00:00
Dan Brown
2b3726702d Revamped workings of WYSIWYG code blocks
Code blocks in tinymce could sometimes end up exploded into the sub
elements of the codemirror display.
This changes the strategy to render codemirror within the shadow dom of
a custom element while preserving the normal pre/code DOM structure.

Still a little instability when moving/adding code blocks within details
blocks but much harder to break things now.
2022-02-09 19:24:27 +00:00
Dan Brown
2b46b00f29 Updated PDF export to open detail blocks 2022-02-09 11:33:23 +00:00
Dan Brown
536ad14276 WYSIWYG details: Improved usage reliability and dark mdoe styles 2022-02-09 11:25:22 +00:00
Dan Brown
a318775cfc Improved wysiwyg details/summary edit controls
- Added specific non-editable/editable filtering to make editing within
  box more reliable.
- Updated toolbar icons and controls.
2022-02-09 10:40:46 +00:00
Dan Brown
9e0b8a9fb6 Started support for WYSIWYG details/summary blocks 2022-02-08 23:08:00 +00:00
Dan Brown
7c692ec588 Changed editor bottom padding technique
- Ensures padding works across FF & Chrome, was only working on FF
  before.
- Fixes sketchy editor positioning focus on FF, since tinyMCE would
  add a hidden element to the bottom of the body which would remove/add
  our body padding causing unstable positioning.
2022-02-08 17:05:38 +00:00
Dan Brown
da0dc7292c Merged in editor translation strings 2022-02-08 15:57:19 +00:00
Dan Brown
045710ea08 Updated with latest styleci changes 2022-02-08 15:29:58 +00:00
Dan Brown
c6ad16dba6 Merge branch 'tinymce' into development 2022-02-08 15:28:56 +00:00
Dan Brown
4ea1f0c633 Merge crowdin changes from users API changes 2022-02-08 15:14:18 +00:00
Dan Brown
f5077c17f4 Merge pull request #3238 from BookStackApp/users_api
User Management API
2022-02-08 13:32:45 +00:00
Dan Brown
c73773930e Merge pull request #3245 from BookStackApp/php7.4
Updated minimum php version from 7.3 to 7.4
2022-02-08 13:31:15 +00:00
Dan Brown
1782618c64 New Crowdin updates (#3251)
* New translations activities.php (Hebrew)

* New translations auth.php (Hebrew)

* New translations common.php (Hebrew)

* New translations activities.php (Hebrew)

* New translations common.php (Hebrew)

* New translations entities.php (Hebrew)

* New translations errors.php (Hebrew)

* New translations validation.php (Spanish)
2022-02-08 13:29:16 +00:00
ististyle
a01bb92989 Update Korean translation (#3256)
* Update validation.php

* Update activities.php

* Update passwords.php

* Update common.php

* Update common.php

* Update auth.php

* Update components.php

* Add files via upload

* Update errors.php

* Update entities.php

* Update entities.php

* Update entities.php

* Update auth.php

* Update activities.php

* Update components.php

* Update components.php

* Update entities.php

* Update components.php

* Update entities.php

* Update errors.php

* Update settings.php

* Update settings.php

* Add files via upload

* Update errors.php
2022-02-08 13:29:01 +00:00
Dan Brown
a2bcf765a8 Split out codemirror JS to its own module
Added a cache-compatible module loading system/pattern to the codebase.
2022-02-08 11:10:01 +00:00
Dan Brown
130dc05517 Updated wysiwyg with dark mode patches
- To better fit in with default BookStack dark theme.
2022-02-08 10:09:17 +00:00
Dan Brown
572d8b3700 Removed unused scroll patch after testing
- Tested on android and ios
- Also checked on translations and removed todo.
2022-02-08 09:42:18 +00:00
Dan Brown
e0d9380055 Aligned some editor events, Changed wysiwyg custom styles loading
- Removed old 'editor-*-update' commands to instead use the aligned
  'editor::replace' command that we already have.
- Changed the way custom styles are loaded for the WYSIWYG editor so we
  don't need an API call but instead scape content from the parent page
  header using comments as identifiers. Added tests to ensure comments
  exist and align.
2022-02-08 01:01:37 +00:00
Dan Brown
15647a0409 Merged color and formats wysiwyg groups 2022-02-08 00:20:36 +00:00
Dan Brown
e88dbe4db3 Added license references to readme attribution 2022-02-08 00:18:29 +00:00
Dan Brown
84c501bcf4 Simplified wysiwyg toolbar with a overflow groups 2022-02-07 23:56:39 +00:00
Dan Brown
c8b6f622f4 Added help/about box to wysiwyg editor
- To display license info along with shortcuts.
- Extracted out plain layout from 503 error page.
- Added tests to ensure license references are as expected.
2022-02-07 23:19:04 +00:00
Dan Brown
ef211a76ae Made WYSIWYG editor translatable
- Created new translation file for editor view.
- Added simple logic to format for tinymce.
- Aligned some of the custom labels we were using.
2022-02-06 21:17:08 +00:00
Dan Brown
921131f999 Modularised our tinymce config and plugins
- Split everything into specific plugin/concern files to make things
  more managable. Means original component file is now simple and much
  of the core config is focused in one place.
2022-02-05 23:15:58 +00:00
Dan Brown
0cde2704d0 Made further tweaks to align with current editor
- Ensured each of the core actions worked at a high level.
- Handled some TinyMCE API changes.
- Moved code block insert to its own button.
2022-02-05 21:20:20 +00:00
Dan Brown
db4093d523 Got TinyMCE 5 added in barely working state
- Some extensions & custom actions not working.
- Updated anything visual to not be breaking (Icons) and anything
  functional that prevented loading.
2022-02-05 16:57:42 +00:00
Dan Brown
e33b587b87 Updated minimum php version from 7.3 to 7.4
Closes #3152
2022-02-04 13:27:11 +00:00
Dan Brown
c8be6ee8a6 Addressed test failures from users API changes 2022-02-04 01:02:13 +00:00
Dan Brown
46e6e239dc Added user API examples 2022-02-04 00:44:56 +00:00
Dan Brown
eb653bda16 Added user-create API endpoint
- Required extracting logic into repo.
- Changed some existing creation paths to standardise behaviour.
- Added test to cover new endpoint.
- Added extra test for user delete to test migration.
- Changed how permission errors are thrown to ensure the right status
  code can be reported when handled in API.
2022-02-04 00:26:19 +00:00
Dan Brown
9e1c8ec82a Added user-update API endpoint
- Required changing the docs generator to handle more complex
  object-style rules. Bit of a hack for some types (password).
- Extracted core update logic to repo for sharing with API.
- Moved user update language string to align with activity/logging
  system.
- Added tests to cover.
2022-02-03 16:52:28 +00:00
Dan Brown
2cd7a48044 Added users-delete API endpoint
- Refactored some delete checks into repo.
- Added tests to cover.
- Moved some translations to align with activity/logging system.
2022-02-03 15:12:50 +00:00
Dan Brown
d089623aac Refactored existing user API work
- Updated routes to use new format.
- Changed how hidden fields are exposed to be more flexible to different
  use-cases.
- Updated properties available on read/list results.
- Started adding testing coverage.
- Removed old unused UserRepo 'getAllUsers' function.

Related to #2701, Progression of #2734
2022-02-03 12:33:26 +00:00
Dan Brown
8d7febe482 Merge branch 'api-endpoint-users' into users_api 2022-02-03 11:38:55 +00:00
Jascha Sticher
4cbd1a9eb5 Extend /users API endpoint
* add /users/{id} to get a single user
* add variable to print fields that are otherwise hidden (e.g. email)
2021-05-06 11:20:08 +02:00
Jascha Sticher
07626669da Test API Endpoint for users 2021-05-05 14:16:15 +02:00
897 changed files with 33972 additions and 16095 deletions

View File

@@ -42,7 +42,7 @@ APP_TIMEZONE=UTC
# overrides can be made. Defaults to disabled.
APP_THEME=false
# Trusted Proxies
# Trusted proxies
# Used to indicate trust of systems that proxy to the application so
# certain header values (Such as "X-Forwarded-For") can be used from the
# incoming proxy request to provide origin detail.
@@ -58,6 +58,13 @@ DB_DATABASE=database_database
DB_USERNAME=database_username
DB_PASSWORD=database_user_password
# MySQL specific connection options
# Path to Certificate Authority (CA) certificate file for your MySQL instance.
# When this option is used host name identity verification will be performed
# which checks the hostname, used by the client, against names within the
# certificate itself (Common Name or Subject Alternative Name).
MYSQL_ATTR_SSL_CA="/path/to/ca.pem"
# Mail system to use
# Can be 'smtp' or 'sendmail'
MAIL_DRIVER=smtp
@@ -136,6 +143,10 @@ STORAGE_URL=false
# Can be 'standard', 'ldap', 'saml2' or 'oidc'
AUTH_METHOD=standard
# Automatically initiate login via external auth system if it's the only auth method.
# Works with saml2 or oidc auth methods.
AUTH_AUTO_INITIATE=false
# Social authentication configuration
# All disabled by default.
# Refer to https://www.bookstackapp.com/docs/admin/third-party-auth/
@@ -216,6 +227,7 @@ LDAP_DUMP_USER_DETAILS=false
LDAP_USER_TO_GROUPS=false
LDAP_GROUP_ATTRIBUTE="memberOf"
LDAP_REMOVE_FROM_GROUPS=false
LDAP_DUMP_USER_GROUPS=false
# SAML authentication configuration
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
@@ -251,7 +263,11 @@ 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
# Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option
@@ -266,7 +282,7 @@ AVATAR_URL=
# Enable diagrams.net integration
# Can simply be true/false to enable/disable the integration.
# Alternatively, It can be URL to the diagrams.net instance you want to use.
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1&configure=1
DRAWIO=true
# Default item listing view
@@ -283,7 +299,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
@@ -324,6 +340,13 @@ ALLOW_UNTRUSTED_SERVER_FETCHING=false
# Setting this option will also auto-adjust cookies to be SameSite=None.
ALLOWED_IFRAME_HOSTS=null
# A list of sources/hostnames that can be loaded within iframes within BookStack.
# Space separated if multiple. BookStack host domain is auto-inferred.
# Can be set to a lone "*" to allow all sources for iframe content (Not advised).
# Defaults to a set of common services.
# Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com"
# The default and maximum item-counts for listing API requests.
API_DEFAULT_ITEM_COUNT=100
API_MAX_ITEM_COUNT=500
@@ -338,3 +361,11 @@ API_REQUESTS_PER_MIN=180
# user identifier (Username or email).
LOG_FAILED_LOGIN_MESSAGE=false
LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
# Alter the precision of IP addresses stored by BookStack.
# Should be a number between 0 and 4, where 4 retains the full IP address
# and 0 completely hides the IP address. As an example, a value of 2 for the
# IP address '146.191.42.4' would result in '146.191.x.x' being logged.
# For the IPv6 address '2001:db8:85a3:8d3:1319:8a2e:370:7348' this would result as:
# '2001:db8:85a3:8d3:x:x:x:x'
IP_ADDRESS_PRECISION=4

1
.github/FUNDING.yml vendored
View File

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

View File

@@ -1,6 +1,5 @@
name: New API Endpoint or API Ability
description: Request a new endpoint or API feature be added
title: "[API Request]: "
labels: [":nut_and_bolt: API Request"]
body:
- type: textarea

View File

@@ -1,6 +1,5 @@
name: Bug Report
description: Create a report to help us improve or fix things
title: "[Bug Report]: "
labels: [":bug: Bug"]
body:
- type: textarea
@@ -36,6 +35,15 @@ body:
description: Provide any additional context and screenshots here to help us solve this issue
validations:
required: false
- type: input
id: browserdetails
attributes:
label: Browser Details
description: |
If this is an issue that occurs when using the BookStack interface, please provide details of the browser used which presents the reported issue.
placeholder: (eg. Firefox 97 (64-bit) on Windows 11)
validations:
required: false
- type: input
id: bsversion
attributes:

View File

@@ -1,9 +1,13 @@
blank_issues_enabled: false
contact_links:
- name: Discord chat support
- name: Discord Chat Support
url: https://discord.gg/ztkBqR2
about: Realtime support / chat with the community and the team.
about: Realtime support & chat with the BookStack community and the team.
- name: Debugging & Common Issues
url: https://www.bookstackapp.com/docs/admin/debugging/
about: Find details on how to debug issues and view common issues with thier resolutions.
about: Find details on how to debug issues and view common issues with their resolutions.
- name: Official Support Plans
url: https://www.bookstackapp.com/support/
about: View our official support plans that offer assured support for business.

View File

@@ -1,6 +1,5 @@
name: Feature Request
description: Request a new language to be added to CrowdIn for you to translate
title: "[Feature Request]: "
description: Request a new feature or idea to be added to BookStack
labels: [":hammer: Feature Request"]
body:
- type: textarea
@@ -13,8 +12,41 @@ body:
- type: textarea
id: benefits
attributes:
label: Describe the benefits this feature would bring to BookStack users
description: Explain the measurable benefits this feature would achieve for existing BookStack users
label: Describe the benefits this would bring to existing BookStack users
description: |
Explain the measurable benefits this feature would achieve for existing BookStack users.
These benefits should details outcomes in terms of what this request solves/achieves, and should not be specific to implementation.
This helps us understand the core desired goal so that a variety of potential implementations could be explored.
This field is important. Lack if input here may lead to early issue closure.
validations:
required: true
- type: textarea
id: already_achieved
attributes:
label: Can the goal of this request already be achieved via other means?
description: |
Yes/No. If yes, please describe how the requested approach fits in with the existing method.
validations:
required: true
- type: checkboxes
id: confirm-search
attributes:
label: Have you searched for an existing open/closed issue?
description: |
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundemental benefit/goal of your request.
options:
- label: I have searched for existing issues and none cover my fundemental request
required: true
- type: dropdown
id: existing_usage
attributes:
label: How long have you been using BookStack?
options:
- Not using yet, just scoping
- 0 to 6 months
- 6 months to 1 year
- 1 to 5 years
- Over 5 years
validations:
required: true
- type: textarea

View File

@@ -1,6 +1,5 @@
name: Language Request
description: Request a new language to be added to CrowdIn for you to translate
title: "[Language Request]: "
description: Request a new language to be added to Crowdin for you to translate
labels: [":earth_africa: Translations"]
assignees:
- ssddanbrown
@@ -24,7 +23,7 @@ body:
This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack).
Please don't use this template to request a new language that you are not prepared to provide translations for.
options:
- label: I confirm I'm offering to help translate for this new language via CrowdIn.
- label: I confirm I'm offering to help translate for this new language via Crowdin.
required: true
- type: markdown
attributes:

View File

@@ -1,6 +1,5 @@
name: Support Request
description: Request support for a specific problem you have not been able to solve yourself
title: "[Support Request]: "
labels: [":dog2: Support"]
body:
- type: checkboxes

View File

@@ -55,6 +55,8 @@ Name :: Languages
@Baptistou :: French
@arcoai :: Spanish
@Jokuna :: Korean
@smartshogu :: German; German Informal
@samadha56 :: Persian
cipi1965 :: Italian
Mykola Ronik (Mantikor) :: Ukrainian
furkanoyk :: Turkish
@@ -136,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
@@ -158,14 +160,14 @@ HenrijsS :: Latvian
Pascal R-B (pborgner) :: German
Boris (Ginfred) :: Russian
Jonas Anker Rasmussen (jonasanker) :: Danish
Gerwin de Keijzer (gdekeijzer) :: Dutch; German; German Informal
Gerwin de Keijzer (gdekeijzer) :: Dutch; German Informal; German
kometchtech :: Japanese
Auri (Atalonica) :: Catalan
Francesco Franchina (ffranchina) :: Italian
Aimrane Kds (aimrane.kds) :: Arabic
whenwesober :: Indonesian
Rem (remkovdhoef) :: Dutch
syn7ax69 :: Bulgarian; Turkish
syn7ax69 :: Bulgarian; Turkish; German
Blaade :: French
Behzad HosseinPoor (behzad.hp) :: Persian
Ole Aldric (Swoy) :: Norwegian Bokmal
@@ -174,7 +176,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: Turkish
REMOVED_USER :: ; Dutch; Turkish
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -215,3 +217,66 @@ Saeed (saeed205) :: Persian
Julesdevops :: French
peter cerny (posli.to.semka) :: Slovak
Pavel Karlin (pavelkarlin) :: Russian
SmokingCrop :: Dutch
Maciej Lebiest (Szwendacz) :: Polish
DiscordDigital :: German; German Informal
Gábor Marton (dodver) :: Hungarian
Jasell :: Swedish
Ghost_chu (ghostchu) :: Chinese Simplified
Ravid Shachar (ravidshachar) :: Hebrew
Helga Guchshenskaya (guchshenskaya) :: Russian
daniel chou (chou0214) :: Chinese Traditional
Manolis PATRIARCHE (m.patriarche) :: French
Mohammed Haboubi (haboubi92) :: Arabic
roncallyt :: Portuguese, Brazilian
goegol :: Dutch
msevgen :: Turkish
Khroners :: French
MASOUD HOSSEINY (masoudme) :: Persian
Thomerson Roncally (roncallyt) :: Portuguese, Brazilian
metaarch :: Bulgarian
Xabi (xabikip) :: Basque
pedromcsousa :: Portuguese
Nir Louk (looknear) :: Hebrew
Alex (qianmengnet) :: Chinese Simplified
stothew :: German
sgenc :: Turkish
Shukrullo (vodiylik) :: Uzbek
William W. (Nevnt) :: Chinese Traditional
eamaro :: Portuguese
Ypsilon-dev :: Arabic
Hieu Vuong Trung (vuongtrunghieu) :: Vietnamese
David Clubb (davidoclubb) :: Welsh
welles freire (wellesximenes) :: Portuguese, Brazilian
Magnus Jensen (MagnusHJensen) :: Danish
Hesley Magno (hesleymagno) :: Portuguese, Brazilian
Éric Gaspar (erga) :: French
Fr3shlama :: German
DSR :: Spanish, Argentina
Andrii Bodnar (andrii-bodnar) :: Ukrainian
Younes el Anjri (younesea28) :: Dutch
Guclu Ozturk (gucluoz) :: Turkish
Atmis :: French
redjack666 :: Chinese Traditional
Ashita007 :: Russian
lihaorr :: Chinese Simplified
Marcus Silber (marcus.silber82) :: German
PellNet :: Croatian
Winetradr :: German
Sebastian Klaus (sebklaus) :: German
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

View File

@@ -1,26 +1,18 @@
name: phpstan
name: analyse-php
on:
push:
branches-ignore:
- l10n_development
pull_request:
branches-ignore:
- l10n_development
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.3']
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: ${{ matrix.php }}
php-version: 8.1
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
@@ -29,13 +21,14 @@ jobs:
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v1
uses: actions/cache@v2
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

@@ -1,19 +1,14 @@
name: test-migrations
on:
push:
branches-ignore:
- l10n_development
pull_request:
branches-ignore:
- l10n_development
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-20.04
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['7.3', '7.4', '8.0', '8.1']
php: ['7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1
@@ -29,10 +24,11 @@ jobs:
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v1
uses: actions/cache@v2
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,19 +1,14 @@
name: phpunit
name: test-php
on:
push:
branches-ignore:
- l10n_development
pull_request:
branches-ignore:
- l10n_development
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-20.04
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['7.3', '7.4', '8.0', '8.1']
php: ['7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1
@@ -29,10 +24,11 @@ jobs:
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v1
uses: actions/cache@v2
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
restore-keys: ${{ runner.os }}-composer-
- name: Start Database
run: |
@@ -53,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

View File

@@ -2,21 +2,15 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Theme;
use BookStack\Interfaces\Loggable;
use BookStack\Theming\ThemeEvents;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
class ActivityLogger
{
protected $permissionService;
public function __construct(PermissionService $permissionService)
{
$this->permissionService = $permissionService;
}
/**
* Add a generic activity event to the database.
*
@@ -35,8 +29,10 @@ class ActivityLogger
}
$activity->save();
$this->setNotification($type);
$this->dispatchWebhooks($type, $detail);
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
}
/**
@@ -44,12 +40,10 @@ class ActivityLogger
*/
protected function newActivityForUser(string $type): Activity
{
$ip = request()->ip() ?? '';
return (new Activity())->forceFill([
'type' => strtolower($type),
'user_id' => user()->id,
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
'ip' => IpFormatter::fromCurrentRequest()->format(),
]);
}

View File

@@ -2,7 +2,7 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
@@ -13,11 +13,11 @@ use Illuminate\Database\Eloquent\Relations\Relation;
class ActivityQueries
{
protected $permissionService;
protected PermissionApplicator $permissions;
public function __construct(PermissionService $permissionService)
public function __construct(PermissionApplicator $permissions)
{
$this->permissionService = $permissionService;
$this->permissions = $permissions;
}
/**
@@ -25,8 +25,8 @@ class ActivityQueries
*/
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['user', 'entity'])
->skip($count * $page)
@@ -78,8 +78,8 @@ class ActivityQueries
*/
public function userActivity(User $user, int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)

View File

@@ -16,17 +16,22 @@ class ActivityType
const CHAPTER_MOVE = 'chapter_move';
const BOOK_CREATE = 'book_create';
const BOOK_CREATE_FROM_CHAPTER = 'book_create_from_chapter';
const BOOK_UPDATE = 'book_update';
const BOOK_DELETE = 'book_delete';
const BOOK_SORT = 'book_sort';
const BOOKSHELF_CREATE = 'bookshelf_create';
const BOOKSHELF_CREATE_FROM_BOOK = 'bookshelf_create_from_book';
const BOOKSHELF_UPDATE = 'bookshelf_update';
const BOOKSHELF_DELETE = 'bookshelf_delete';
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

@@ -3,17 +3,14 @@
namespace BookStack\Actions;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Theme;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use BookStack\Theming\ThemeEvents;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -24,31 +21,16 @@ class DispatchWebhookJob implements ShouldQueue
use Queueable;
use SerializesModels;
/**
* @var Webhook
*/
protected $webhook;
/**
* @var string
*/
protected $event;
protected Webhook $webhook;
protected string $event;
protected User $initiator;
protected int $initiatedTime;
/**
* @var string|Loggable
*/
protected $detail;
/**
* @var User
*/
protected $initiator;
/**
* @var int
*/
protected $initiatedTime;
/**
* Create a new job instance.
*
@@ -70,8 +52,8 @@ class DispatchWebhookJob implements ShouldQueue
*/
public function handle()
{
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail);
$webhookData = $themeResponse ?? $this->buildWebhookData();
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime);
$webhookData = $themeResponse ?? WebhookFormatter::getDefault($this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime)->format();
$lastError = null;
try {
@@ -97,36 +79,4 @@ class DispatchWebhookJob implements ShouldQueue
$this->webhook->save();
}
protected function buildWebhookData(): array
{
$textParts = [
$this->initiator->name,
trans('activities.' . $this->event),
];
if ($this->detail instanceof Entity) {
$textParts[] = '"' . $this->detail->name . '"';
}
$data = [
'event' => $this->event,
'text' => implode(' ', $textParts),
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
'triggered_by' => $this->initiator->attributesToArray(),
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
'webhook_id' => $this->webhook->id,
'webhook_name' => $this->webhook->name,
];
if (method_exists($this->detail, 'getUrl')) {
$data['url'] = $this->detail->getUrl();
}
if ($this->detail instanceof Model) {
$data['related_item'] = $this->detail->attributesToArray();
}
return $data;
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace BookStack\Actions;
class IpFormatter
{
protected string $ip;
protected int $precision;
public function __construct(string $ip, int $precision)
{
$this->ip = trim($ip);
$this->precision = max(0, min($precision, 4));
}
public function format(): string
{
if (empty($this->ip) || $this->precision === 4) {
return $this->ip;
}
return $this->isIpv6() ? $this->maskIpv6() : $this->maskIpv4();
}
protected function maskIpv4(): string
{
$exploded = $this->explodeAndExpandIp('.', 4);
$maskGroupCount = min(4 - $this->precision, count($exploded));
for ($i = 0; $i < $maskGroupCount; $i++) {
$exploded[3 - $i] = 'x';
}
return implode('.', $exploded);
}
protected function maskIpv6(): string
{
$exploded = $this->explodeAndExpandIp(':', 8);
$maskGroupCount = min(8 - ($this->precision * 2), count($exploded));
for ($i = 0; $i < $maskGroupCount; $i++) {
$exploded[7 - $i] = 'x';
}
return implode(':', $exploded);
}
protected function isIpv6(): bool
{
return strpos($this->ip, ':') !== false;
}
protected function explodeAndExpandIp(string $separator, int $targetLength): array
{
$exploded = explode($separator, $this->ip);
while (count($exploded) < $targetLength) {
$emptyIndex = array_search('', $exploded) ?: count($exploded) - 1;
array_splice($exploded, $emptyIndex, 0, '0');
}
$emptyIndex = array_search('', $exploded);
if ($emptyIndex !== false) {
$exploded[$emptyIndex] = '0';
}
return $exploded;
}
public static function fromCurrentRequest(): self
{
$ip = request()->ip() ?? '';
if (config('app.env') === 'demo') {
$ip = '127.0.0.1';
}
return new self($ip, config('app.ip_address_precision'));
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace BookStack\Actions;
class TagClassGenerator
{
protected array $tags;
/**
* @param Tag[] $tags
*/
public function __construct(array $tags)
{
$this->tags = $tags;
}
/**
* @return string[]
*/
public function generate(): array
{
$classes = [];
foreach ($this->tags as $tag) {
$name = $this->normalizeTagClassString($tag->name);
$value = $this->normalizeTagClassString($tag->value);
$classes[] = 'tag-name-' . $name;
if ($value) {
$classes[] = 'tag-value-' . $value;
$classes[] = 'tag-pair-' . $name . '-' . $value;
}
}
return array_unique($classes);
}
public function generateAsString(): string
{
return implode(' ', $this->generate());
}
protected function normalizeTagClassString(string $value): string
{
$value = str_replace(' ', '', strtolower($value));
$value = str_replace('-', '', strtolower($value));
return $value;
}
}

View File

@@ -2,7 +2,7 @@
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
@@ -10,12 +10,11 @@ use Illuminate\Support\Facades\DB;
class TagRepo
{
protected $tag;
protected $permissionService;
protected PermissionApplicator $permissions;
public function __construct(PermissionService $ps)
public function __construct(PermissionApplicator $permissions)
{
$this->permissionService = $ps;
$this->permissions = $permissions;
}
/**
@@ -28,10 +27,10 @@ class TagRepo
'name',
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
DB::raw('COUNT(id) as usages'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Page\', 1, 0)) as page_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Chapter\', 1, 0)) as chapter_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\BookShelf\', 1, 0)) as shelf_count'),
DB::raw('SUM(IF(entity_type = \'page\', 1, 0)) as page_count'),
DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'),
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
])
->orderBy($nameFilter ? 'value' : 'name');
@@ -51,7 +50,7 @@ class TagRepo
});
}
return $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
}
/**
@@ -70,7 +69,7 @@ class TagRepo
$query = $query->orderBy('count', 'desc')->take(50);
}
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['name'])->pluck('name');
}
@@ -96,7 +95,7 @@ class TagRepo
$query = $query->where('name', '=', $tagName);
}
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['value'])->pluck('value');
}

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

@@ -0,0 +1,124 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use Illuminate\Support\Carbon;
class WebhookFormatter
{
protected Webhook $webhook;
protected string $event;
protected User $initiator;
protected int $initiatedTime;
/**
* @var string|Loggable
*/
protected $detail;
/**
* @var array{condition: callable(string, Model):bool, format: callable(Model):void}[]
*/
protected $modelFormatters = [];
public function __construct(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime)
{
$this->webhook = $webhook;
$this->event = $event;
$this->initiator = $initiator;
$this->initiatedTime = $initiatedTime;
$this->detail = is_object($detail) ? clone $detail : $detail;
}
public function format(): array
{
$data = [
'event' => $this->event,
'text' => $this->formatText(),
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
'triggered_by' => $this->initiator->attributesToArray(),
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
'webhook_id' => $this->webhook->id,
'webhook_name' => $this->webhook->name,
];
if (method_exists($this->detail, 'getUrl')) {
$data['url'] = $this->detail->getUrl();
}
if ($this->detail instanceof Model) {
$data['related_item'] = $this->formatModel();
}
return $data;
}
/**
* @param callable(string, Model):bool $condition
* @param callable(Model):void $format
*/
public function addModelFormatter(callable $condition, callable $format): void
{
$this->modelFormatters[] = [
'condition' => $condition,
'format' => $format,
];
}
public function addDefaultModelFormatters(): void
{
// Load entity owner, creator, updater details
$this->addModelFormatter(
fn ($event, $model) => ($model instanceof Entity),
fn ($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy'])
);
// Load revision detail for page update and create events
$this->addModelFormatter(
fn ($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)),
fn ($model) => $model->load('currentRevision')
);
}
protected function formatModel(): array
{
/** @var Model $model */
$model = $this->detail;
$model->unsetRelations();
foreach ($this->modelFormatters as $formatter) {
if ($formatter['condition']($this->event, $model)) {
$formatter['format']($model);
}
}
return $model->toArray();
}
protected function formatText(): string
{
$textParts = [
$this->initiator->name,
trans('activities.' . $this->event),
];
if ($this->detail instanceof Entity) {
$textParts[] = '"' . $this->detail->name . '"';
}
return implode(' ', $textParts);
}
public static function getDefault(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime): self
{
$instance = new self($event, $webhook, $detail, $initiator, $initiatedTime);
$instance->addDefaultModelFormatters();
return $instance;
}
}

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

@@ -3,11 +3,13 @@
namespace BookStack\Api;
use BookStack\Http\Controllers\Api\ApiController;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
@@ -100,11 +102,37 @@ class ApiDocsGenerator
$this->controllerClasses[$className] = $class;
}
$rules = $class->getValdationRules()[$methodName] ?? [];
$rules = collect($class->getValidationRules()[$methodName] ?? [])->map(function ($validations) {
return array_map(function ($validation) {
return $this->getValidationAsString($validation);
}, $validations);
})->toArray();
return empty($rules) ? null : $rules;
}
/**
* Convert the given validation message to a readable string.
*/
protected function getValidationAsString($validation): string
{
if (is_string($validation)) {
return $validation;
}
if (is_object($validation) && method_exists($validation, '__toString')) {
return strval($validation);
}
if ($validation instanceof Password) {
return 'min:8';
}
$class = get_class($validation);
throw new Exception("Cannot provide string representation of rule for class: {$class}");
}
/**
* Parse out the description text from a class method comment.
*/

View File

@@ -2,8 +2,10 @@
namespace BookStack\Api;
use BookStack\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ListingResponseBuilder
@@ -12,6 +14,11 @@ class ListingResponseBuilder
protected $request;
protected $fields;
/**
* @var array<callable>
*/
protected $resultModifiers = [];
protected $filterOperators = [
'eq' => '=',
'ne' => '!=',
@@ -24,6 +31,7 @@ class ListingResponseBuilder
/**
* ListingResponseBuilder constructor.
* The given fields will be forced visible within the model results.
*/
public function __construct(Builder $query, Request $request, array $fields)
{
@@ -35,12 +43,16 @@ class ListingResponseBuilder
/**
* Get the response from this builder.
*/
public function toResponse()
public function toResponse(): JsonResponse
{
$filteredQuery = $this->filterQuery($this->query);
$total = $filteredQuery->count();
$data = $this->fetchData($filteredQuery);
$data = $this->fetchData($filteredQuery)->each(function ($model) {
foreach ($this->resultModifiers as $modifier) {
$modifier($model);
}
});
return response()->json([
'data' => $data,
@@ -49,7 +61,17 @@ class ListingResponseBuilder
}
/**
* Fetch the data to return in the response.
* Add a callback to modify each element of the results.
*
* @param (callable(Model)) $modifier
*/
public function modifyResults($modifier): void
{
$this->resultModifiers[] = $modifier;
}
/**
* Fetch the data to return within the response.
*/
protected function fetchData(Builder $query): Collection
{

View File

@@ -28,10 +28,8 @@ class GroupSyncService
*/
protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool
{
$externalAuthIds = explode(',', strtolower($externalId));
foreach ($externalAuthIds as $externalAuthId) {
if (in_array(trim($externalAuthId), $groupNames)) {
foreach ($this->parseRoleExternalAuthId($externalId) as $externalAuthId) {
if (in_array($externalAuthId, $groupNames)) {
return true;
}
}
@@ -39,6 +37,18 @@ class GroupSyncService
return false;
}
protected function parseRoleExternalAuthId(string $externalId): array
{
$inputIds = preg_split('/(?<!\\\),/', strtolower($externalId));
$cleanIds = [];
foreach ($inputIds as $inputId) {
$cleanIds[] = str_replace('\,', ',', trim($inputId));
}
return $cleanIds;
}
/**
* Match an array of group names to BookStack system roles.
* Formats group names to be lower-case and hyphenated.

View File

@@ -5,6 +5,7 @@ namespace BookStack\Auth\Access\Guards;
use BookStack\Auth\Access\LdapService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\LdapException;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
@@ -15,7 +16,7 @@ use Illuminate\Support\Str;
class LdapSessionGuard extends ExternalBaseSessionGuard
{
protected $ldapService;
protected LdapService $ldapService;
/**
* LdapSessionGuard constructor.
@@ -59,8 +60,9 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
* @param array $credentials
* @param bool $remember
*
* @throws LdapException*@throws \BookStack\Exceptions\JsonDebugException
* @throws LoginAttemptException
* @throws LdapException
* @throws JsonDebugException
*
* @return bool
*/
@@ -84,7 +86,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
try {
$user = $this->createNewFromLdapAndCreds($userDetails, $credentials);
} catch (UserRegistrationException $exception) {
throw new LoginAttemptException($exception->message);
throw new LoginAttemptException($exception->getMessage());
}
}

View File

@@ -15,12 +15,17 @@ use Illuminate\Support\Facades\Log;
*/
class LdapService
{
protected $ldap;
protected $groupSyncService;
protected Ldap $ldap;
protected GroupSyncService $groupSyncService;
protected UserAvatars $userAvatars;
/**
* @var resource
*/
protected $ldapConnection;
protected $userAvatars;
protected $config;
protected $enabled;
protected array $config;
protected bool $enabled;
/**
* LdapService constructor.
@@ -100,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']) {
@@ -274,6 +279,7 @@ class LdapService
* Get the groups a user is a part of on ldap.
*
* @throws LdapException
* @throws JsonDebugException
*/
public function getUserGroups(string $userName): array
{
@@ -285,8 +291,17 @@ class LdapService
}
$userGroups = $this->groupFilter($user);
$allGroups = $this->getGroupsRecursive($userGroups, []);
return $this->getGroupsRecursive($userGroups, []);
if ($this->config['dump_user_groups']) {
throw new JsonDebugException([
'details_from_ldap' => $user,
'parsed_direct_user_groups' => $userGroups,
'parsed_recursive_user_groups' => $allGroups,
]);
}
return $allGroups;
}
/**
@@ -369,6 +384,7 @@ class LdapService
* Sync the LDAP groups to the user roles for the current user.
*
* @throws LdapException
* @throws JsonDebugException
*/
public function syncGroups(User $user, string $username)
{

View File

@@ -0,0 +1,9 @@
<?php
namespace BookStack\Auth\Access\Oidc;
use Exception;
class OidcException extends Exception
{
}

View File

@@ -2,6 +2,8 @@
namespace BookStack\Auth\Access\Oidc;
class OidcIssuerDiscoveryException extends \Exception
use Exception;
class OidcIssuerDiscoveryException extends Exception
{
}

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

@@ -2,22 +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\OpenIdConnectException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use function config;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use Psr\Http\Client\ClientExceptionInterface;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Http\Client\ClientInterface as HttpClient;
use function trans;
use function url;
/**
* Class OpenIdConnectService
@@ -25,23 +21,31 @@ use function url;
*/
class OidcService
{
protected $registrationService;
protected $loginService;
protected $httpClient;
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;
}
/**
* Initiate an authorization flow.
*
* @throws OidcException
*
* @return array{url: string, state: string}
*/
public function login(): array
@@ -57,14 +61,15 @@ class OidcService
/**
* Process the Authorization response from the authorization server and
* return the matching, or new if registration active, user matched to
* the authorization server.
* Returns null if not authenticated.
* return the matching, or new if registration active, user matched to the
* authorization server. Throws if the user cannot be auth if not authenticated.
*
* @throws Exception
* @throws ClientExceptionInterface
* @throws JsonDebugException
* @throws OidcException
* @throws StoppedAuthenticationException
* @throws IdentityProviderException
*/
public function processAuthorizeResponse(?string $authorizationCode): ?User
public function processAuthorizeResponse(?string $authorizationCode): User
{
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
@@ -78,8 +83,7 @@ class OidcService
}
/**
* @throws OidcIssuerDiscoveryException
* @throws ClientExceptionInterface
* @throws OidcException
*/
protected function getProviderSettings(): OidcProviderSettings
{
@@ -100,7 +104,11 @@ class OidcService
// Run discovery
if ($config['discover'] ?? false) {
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
try {
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
} catch (OidcIssuerDiscoveryException $exception) {
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
}
}
$settings->validate();
@@ -113,10 +121,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);
}
/**
@@ -141,10 +170,32 @@ 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
{
@@ -154,6 +205,7 @@ class OidcService
'external_id' => $id,
'email' => $token->getClaim('email'),
'name' => $this->getUserDisplayName($token, $id),
'groups' => $this->getUserGroups($token),
];
}
@@ -161,9 +213,8 @@ class OidcService
* Processes a received access token for a user. Login the user when
* they exist, optionally registering them automatically.
*
* @throws OpenIdConnectException
* @throws OidcException
* @throws JsonDebugException
* @throws UserRegistrationException
* @throws StoppedAuthenticationException
*/
protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
@@ -182,28 +233,34 @@ class OidcService
try {
$idToken->validate($settings->clientId);
} catch (OidcInvalidTokenException $exception) {
throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}");
throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
}
$userDetails = $this->getUserDetails($idToken);
$isLoggedIn = auth()->check();
if (empty($userDetails['email'])) {
throw new OpenIdConnectException(trans('errors.oidc_no_email_address'));
throw new OidcException(trans('errors.oidc_no_email_address'));
}
if ($isLoggedIn) {
throw new OpenIdConnectException(trans('errors.oidc_already_logged_in'), '/login');
throw new OidcException(trans('errors.oidc_already_logged_in'));
}
$user = $this->registrationService->findOrRegister(
$userDetails['name'],
$userDetails['email'],
$userDetails['external_id']
);
try {
$user = $this->registrationService->findOrRegister(
$userDetails['name'],
$userDetails['email'],
$userDetails['external_id']
);
} catch (UserRegistrationException $exception) {
throw new OidcException($exception->getMessage());
}
if ($user === null) {
throw new OpenIdConnectException(trans('errors.oidc_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
if ($this->shouldSyncGroups()) {
$groups = $userDetails['groups'];
$detachExisting = $this->config()['remove_from_groups'];
$this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
}
$this->loginService->login($user, 'oidc');
@@ -218,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

@@ -96,7 +96,8 @@ class RegistrationService
}
// Create the user
$newUser = $this->userRepo->registerNew($userData, $emailConfirmed);
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
$newUser->attachDefaultRole();
// Assign social account if given
if ($socialAccount) {

View File

@@ -109,9 +109,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()) {

View File

@@ -0,0 +1,405 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\DB;
/**
* Joint permissions provide a pre-query "cached" table of view permissions for all core entity
* types for all roles in the system. This class generates out that table for different scenarios.
*/
class JointPermissionBuilder
{
/**
* @var array<string, array<int, SimpleEntityData>>
*/
protected $entityCache;
/**
* Re-generate all entity permission from scratch.
*/
public function rebuildForAll()
{
JointPermission::query()->truncate();
// Get all roles (Should be the most limited dimension)
$roles = Role::query()->with('permissions')->get()->all();
// Chunk through all books
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
$this->createManyJointPermissions($shelves->all(), $roles);
});
}
/**
* Rebuild the entity jointPermissions for a particular entity.
*/
public function rebuildForEntity(Entity $entity)
{
$entities = [$entity];
if ($entity instanceof Book) {
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
$this->buildJointPermissionsForBooks($books, Role::query()->with('permissions')->get()->all(), true);
return;
}
/** @var BookChild $entity */
if ($entity->book) {
$entities[] = $entity->book;
}
if ($entity instanceof Page && $entity->chapter_id) {
$entities[] = $entity->chapter;
}
if ($entity instanceof Chapter) {
foreach ($entity->pages as $page) {
$entities[] = $page;
}
}
$this->buildJointPermissionsForEntities($entities);
}
/**
* Build the entity jointPermissions for a particular role.
*/
public function rebuildForRole(Role $role)
{
$roles = [$role];
$role->jointPermissions()->delete();
$role->load('permissions');
// Chunk through all books
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
Bookshelf::query()->select(['id', 'restricted', '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([
'chapters' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
},
'pages' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
},
]);
}
/**
* Build joint permissions for the given book and role combinations.
*/
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
{
$entities = clone $books;
/** @var Book $book */
foreach ($books->all() as $book) {
foreach ($book->getRelation('chapters') as $chapter) {
$entities->push($chapter);
}
foreach ($book->getRelation('pages') as $page) {
$entities->push($page);
}
}
if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($entities->all());
}
$this->createManyJointPermissions($entities->all(), $roles);
}
/**
* Rebuild the entity jointPermissions for a collection of entities.
*/
protected function buildJointPermissionsForEntities(array $entities)
{
$roles = Role::query()->get()->values()->all();
$this->deleteManyJointPermissionsForEntities($entities);
$this->createManyJointPermissions($entities, $roles);
}
/**
* Delete all the entity jointPermissions for a list of entities.
*
* @param Entity[] $entities
*/
protected function deleteManyJointPermissionsForEntities(array $entities)
{
$simpleEntities = $this->entitiesToSimpleEntities($entities);
$idsByType = $this->entitiesToTypeIdMap($simpleEntities);
DB::transaction(function () use ($idsByType) {
foreach ($idsByType as $type => $ids) {
foreach (array_chunk($ids, 1000) as $idChunk) {
DB::table('joint_permissions')
->where('entity_type', '=', $type)
->whereIn('entity_id', $idChunk)
->delete();
}
}
});
}
/**
* @param Entity[] $entities
*
* @return SimpleEntityData[]
*/
protected function entitiesToSimpleEntities(array $entities): array
{
$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;
$simpleEntities[] = $simple;
}
return $simpleEntities;
}
/**
* Create & Save entity jointPermissions for many entities and roles.
*
* @param Entity[] $entities
* @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;
}
// Create a mapping of role permissions
$rolePermissionMap = [];
foreach ($roles as $role) {
foreach ($role->permissions as $permission) {
$rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
}
}
// Create Joint Permission Data
foreach ($entities as $entity) {
foreach ($roles as $role) {
$jointPermissions[] = $this->createJointPermissionData(
$entity,
$role->getRawAttribute('id'),
$permissionMap,
$rolePermissionMap,
$role->system_name === 'admin'
);
}
}
DB::transaction(function () use ($jointPermissions) {
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
DB::table('joint_permissions')->insert($jointPermissionChunk);
}
});
}
/**
* From the given entity list, provide back a mapping of entity types to
* the ids of that given type. The type used is the DB morph class.
*
* @param SimpleEntityData[] $entities
*
* @return array<string, int[]>
*/
protected function entitiesToTypeIdMap(array $entities): array
{
$idsByType = [];
foreach ($entities as $entity) {
if (!isset($idsByType[$entity->type])) {
$idsByType[$entity->type] = [];
}
$idsByType[$entity->type][] = $entity->id;
}
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
{
$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;
}
/**
* 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
{
return [
'entity_id' => $entity->id,
'entity_type' => $entity->type,
'has_permission' => $permissionAll,
'has_permission_own' => $permissionOwn,
'owned_by' => $entity->owned_by,
'role_id' => $roleId,
];
}
}

View File

@@ -0,0 +1,258 @@
<?php
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;
use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use InvalidArgumentException;
class PermissionApplicator
{
/**
* Checks if an entity has a restriction set upon it.
*
* @param HasCreatorAndUpdater|HasOwner $ownable
*/
public function checkOwnableUserAccess(Model $ownable, string $permission): bool
{
$explodedPermission = explode('-', $permission);
$action = $explodedPermission[1] ?? $explodedPermission[0];
$fullPermission = count($explodedPermission) > 1 ? $permission : $ownable->getMorphClass() . '-' . $permission;
$user = $this->currentUser();
$userRoleIds = $this->getCurrentUserRoleIds();
$allRolePermission = $user->can($fullPermission . '-all');
$ownRolePermission = $user->can($fullPermission . '-own');
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
$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
if (in_array($explodedPermission[0], $nonJointPermissions)) {
return $hasRolePermission;
}
$hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action);
return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions;
}
/**
* Check if there are permissions that are applicable for the given entity item, action and roles.
* Returns null when no entity permissions are in force.
*/
protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool
{
$adminRoleId = Role::getSystemRole('admin')->id;
if (in_array($adminRoleId, $userRoleIds)) {
return true;
}
$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 (is_null($currentEntity->restricted)) {
throw new InvalidArgumentException('Entity restricted field used but has not been loaded');
}
if ($currentEntity->restricted) {
return $currentEntity->permissions()
->whereIn('role_id', $userRoleIds)
->where('action', '=', $action)
->count() > 0;
}
}
return null;
}
/**
* Checks if a user has the given permission for any items in the system.
* Can be passed an entity instance to filter on a specific type.
*/
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');
}
$permissionQuery = EntityPermission::query()
->where('action', '=', $action)
->whereIn('role_id', $this->getCurrentUserRoleIds());
if (!empty($entityClass)) {
/** @var Entity $entityInstance */
$entityInstance = app()->make($entityClass);
$permissionQuery = $permissionQuery->where('restrictable_type', '=', $entityInstance->getMorphClass());
}
$hasPermission = $permissionQuery->count() > 0;
return $hasPermission;
}
/**
* Limit the given entity query so that the query will only
* return items that the user has view permission for.
*/
public function restrictEntityQuery(Builder $query): Builder
{
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);
});
});
});
}
/**
* Extend the given page query to ensure draft items are not visible
* unless created by the given user.
*/
public function restrictDraftsOnPageQuery(Builder $query): Builder
{
return $query->where(function (Builder $query) {
$query->where('draft', '=', false)
->orWhere(function (Builder $query) {
$query->where('draft', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
});
}
/**
* 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)
{
$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)
->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;
}
/**
* Add conditions to a query for a model that's a relation of a page, so only the model results
* on visible pages are returned by the query.
* Is effectively the same as "restrictEntityRelationQuery" but takes into account page drafts
* while not expecting a polymorphic relation, Just a simpler one-page-to-many-relations set-up.
*/
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);
});
};
$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);
});
}
/**
* Get the current user.
*/
protected function currentUser(): User
{
return user();
}
/**
* Get the roles for the current logged-in user.
*
* @return int[]
*/
protected function getCurrentUserRoleIds(): array
{
if (auth()->guest()) {
return [Role::getSystemRole('public')->id];
}
return $this->currentUser()->roles->pluck('id')->values()->all();
}
}

View File

@@ -1,719 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Throwable;
class PermissionService
{
/**
* @var ?array
*/
protected $userRoles = null;
/**
* @var ?User
*/
protected $currentUserModel = null;
/**
* @var Connection
*/
protected $db;
/**
* @var array
*/
protected $entityCache;
/**
* PermissionService constructor.
*/
public function __construct(Connection $db)
{
$this->db = $db;
}
/**
* Set the database connection.
*/
public function setConnection(Connection $connection)
{
$this->db = $connection;
}
/**
* Prepare the local entity cache and ensure it's empty.
*
* @param Entity[] $entities
*/
protected function readyEntityCache(array $entities = [])
{
$this->entityCache = [];
foreach ($entities as $entity) {
$class = get_class($entity);
if (!isset($this->entityCache[$class])) {
$this->entityCache[$class] = collect();
}
$this->entityCache[$class]->put($entity->id, $entity);
}
}
/**
* Get a book via ID, Checks local cache.
*/
protected function getBook(int $bookId): ?Book
{
if (isset($this->entityCache[Book::class]) && $this->entityCache[Book::class]->has($bookId)) {
return $this->entityCache[Book::class]->get($bookId);
}
return Book::query()->withTrashed()->find($bookId);
}
/**
* Get a chapter via ID, Checks local cache.
*/
protected function getChapter(int $chapterId): ?Chapter
{
if (isset($this->entityCache[Chapter::class]) && $this->entityCache[Chapter::class]->has($chapterId)) {
return $this->entityCache[Chapter::class]->get($chapterId);
}
return Chapter::query()
->withTrashed()
->find($chapterId);
}
/**
* Get the roles for the current logged in user.
*/
protected function getCurrentUserRoles(): array
{
if (!is_null($this->userRoles)) {
return $this->userRoles;
}
if (auth()->guest()) {
$this->userRoles = [Role::getSystemRole('public')->id];
} else {
$this->userRoles = $this->currentUser()->roles->pluck('id')->values()->all();
}
return $this->userRoles;
}
/**
* Re-generate all entity permission from scratch.
*/
public function buildJointPermissions()
{
JointPermission::query()->truncate();
$this->readyEntityCache();
// Get all roles (Should be the most limited dimension)
$roles = Role::query()->with('permissions')->get()->all();
// Chunk through all books
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
$this->buildJointPermissionsForShelves($shelves, $roles);
});
}
/**
* Get a query for fetching a book with it's children.
*/
protected function bookFetchQuery(): Builder
{
return Book::query()->withTrashed()
->select(['id', 'restricted', 'owned_by'])->with([
'chapters' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
},
'pages' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
},
]);
}
/**
* Build joint permissions for the given shelf and role combinations.
*
* @throws Throwable
*/
protected function buildJointPermissionsForShelves(EloquentCollection $shelves, array $roles, bool $deleteOld = false)
{
if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($shelves->all());
}
$this->createManyJointPermissions($shelves->all(), $roles);
}
/**
* Build joint permissions for the given book and role combinations.
*
* @throws Throwable
*/
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
{
$entities = clone $books;
/** @var Book $book */
foreach ($books->all() as $book) {
foreach ($book->getRelation('chapters') as $chapter) {
$entities->push($chapter);
}
foreach ($book->getRelation('pages') as $page) {
$entities->push($page);
}
}
if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($entities->all());
}
$this->createManyJointPermissions($entities->all(), $roles);
}
/**
* Rebuild the entity jointPermissions for a particular entity.
*
* @throws Throwable
*/
public function buildJointPermissionsForEntity(Entity $entity)
{
$entities = [$entity];
if ($entity instanceof Book) {
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
$this->buildJointPermissionsForBooks($books, Role::query()->get()->all(), true);
return;
}
/** @var BookChild $entity */
if ($entity->book) {
$entities[] = $entity->book;
}
if ($entity instanceof Page && $entity->chapter_id) {
$entities[] = $entity->chapter;
}
if ($entity instanceof Chapter) {
foreach ($entity->pages as $page) {
$entities[] = $page;
}
}
$this->buildJointPermissionsForEntities($entities);
}
/**
* Rebuild the entity jointPermissions for a collection of entities.
*
* @throws Throwable
*/
public function buildJointPermissionsForEntities(array $entities)
{
$roles = Role::query()->get()->values()->all();
$this->deleteManyJointPermissionsForEntities($entities);
$this->createManyJointPermissions($entities, $roles);
}
/**
* Build the entity jointPermissions for a particular role.
*/
public function buildJointPermissionForRole(Role $role)
{
$roles = [$role];
$this->deleteManyJointPermissionsForRoles($roles);
// Chunk through all books
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
->chunk(50, function ($shelves) use ($roles) {
$this->buildJointPermissionsForShelves($shelves, $roles);
});
}
/**
* Delete the entity jointPermissions attached to a particular role.
*/
public function deleteJointPermissionsForRole(Role $role)
{
$this->deleteManyJointPermissionsForRoles([$role]);
}
/**
* Delete all of the entity jointPermissions for a list of entities.
*
* @param Role[] $roles
*/
protected function deleteManyJointPermissionsForRoles($roles)
{
$roleIds = array_map(function ($role) {
return $role->id;
}, $roles);
JointPermission::query()->whereIn('role_id', $roleIds)->delete();
}
/**
* Delete the entity jointPermissions for a particular entity.
*
* @param Entity $entity
*
* @throws Throwable
*/
public function deleteJointPermissionsForEntity(Entity $entity)
{
$this->deleteManyJointPermissionsForEntities([$entity]);
}
/**
* Delete all of the entity jointPermissions for a list of entities.
*
* @param Entity[] $entities
*
* @throws Throwable
*/
protected function deleteManyJointPermissionsForEntities(array $entities)
{
if (count($entities) === 0) {
return;
}
$this->db->transaction(function () use ($entities) {
foreach (array_chunk($entities, 1000) as $entityChunk) {
$query = $this->db->table('joint_permissions');
foreach ($entityChunk as $entity) {
$query->orWhere(function (QueryBuilder $query) use ($entity) {
$query->where('entity_id', '=', $entity->id)
->where('entity_type', '=', $entity->getMorphClass());
});
}
$query->delete();
}
});
}
/**
* Create & Save entity jointPermissions for many entities and roles.
*
* @param Entity[] $entities
* @param Role[] $roles
*
* @throws Throwable
*/
protected function createManyJointPermissions(array $entities, array $roles)
{
$this->readyEntityCache($entities);
$jointPermissions = [];
// Fetch Entity Permissions and create a mapping of entity restricted statuses
$entityRestrictedMap = [];
$permissionFetch = EntityPermission::query();
foreach ($entities as $entity) {
$entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted'));
$permissionFetch->orWhere(function ($query) use ($entity) {
$query->where('restrictable_id', '=', $entity->id)->where('restrictable_type', '=', $entity->getMorphClass());
});
}
$permissions = $permissionFetch->get();
// Create a mapping of explicit entity permissions
$permissionMap = [];
foreach ($permissions as $permission) {
$key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id . ':' . $permission->action;
$isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
$permissionMap[$key] = $isRestricted;
}
// Create a mapping of role permissions
$rolePermissionMap = [];
foreach ($roles as $role) {
foreach ($role->permissions as $permission) {
$rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
}
}
// Create Joint Permission Data
foreach ($entities as $entity) {
foreach ($roles as $role) {
foreach ($this->getActions($entity) as $action) {
$jointPermissions[] = $this->createJointPermissionData($entity, $role, $action, $permissionMap, $rolePermissionMap);
}
}
}
$this->db->transaction(function () use ($jointPermissions) {
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
$this->db->table('joint_permissions')->insert($jointPermissionChunk);
}
});
}
/**
* Get the actions related to an entity.
*/
protected function getActions(Entity $entity): array
{
$baseActions = ['view', 'update', 'delete'];
if ($entity instanceof Chapter || $entity instanceof Book) {
$baseActions[] = 'page-create';
}
if ($entity instanceof Book) {
$baseActions[] = 'chapter-create';
}
return $baseActions;
}
/**
* Create entity permission data for an entity and role
* for a particular action.
*/
protected function createJointPermissionData(Entity $entity, Role $role, string $action, array $permissionMap, array $rolePermissionMap): array
{
$permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action;
$roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']);
$roleHasPermissionOwn = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-own']);
$explodedAction = explode('-', $action);
$restrictionAction = end($explodedAction);
if ($role->system_name === 'admin') {
return $this->createJointPermissionDataArray($entity, $role, $action, true, true);
}
if ($entity->restricted) {
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $role, $restrictionAction);
return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
}
if ($entity instanceof Book || $entity instanceof Bookshelf) {
return $this->createJointPermissionDataArray($entity, $role, $action, $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, $role, $restrictionAction);
$hasPermissiveAccessToParents = !$book->restricted;
// For pages with a chapter, Check if explicit permissions are set on the Chapter
if ($entity instanceof Page && intval($entity->chapter_id) !== 0) {
$chapter = $this->getChapter($entity->chapter_id);
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
if ($chapter->restricted) {
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $role, $restrictionAction);
}
}
return $this->createJointPermissionDataArray(
$entity,
$role,
$action,
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
);
}
/**
* Check for an active restriction in an entity map.
*/
protected function mapHasActiveRestriction(array $entityMap, Entity $entity, Role $role, string $action): bool
{
$key = $entity->getMorphClass() . ':' . $entity->getRawAttribute('id') . ':' . $role->getRawAttribute('id') . ':' . $action;
return $entityMap[$key] ?? false;
}
/**
* Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion.
*/
protected function createJointPermissionDataArray(Entity $entity, Role $role, string $action, bool $permissionAll, bool $permissionOwn): array
{
return [
'role_id' => $role->getRawAttribute('id'),
'entity_id' => $entity->getRawAttribute('id'),
'entity_type' => $entity->getMorphClass(),
'action' => $action,
'has_permission' => $permissionAll,
'has_permission_own' => $permissionOwn,
'owned_by' => $entity->getRawAttribute('owned_by'),
];
}
/**
* Checks if an entity has a restriction set upon it.
*
* @param HasCreatorAndUpdater|HasOwner $ownable
*/
public function checkOwnableUserAccess(Model $ownable, string $permission): bool
{
$explodedPermission = explode('-', $permission);
$baseQuery = $ownable->newQuery()->where('id', '=', $ownable->id);
$action = end($explodedPermission);
$user = $this->currentUser();
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
// Handle non entity specific jointPermissions
if (in_array($explodedPermission[0], $nonJointPermissions)) {
$allPermission = $user && $user->can($permission . '-all');
$ownPermission = $user && $user->can($permission . '-own');
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
$isOwner = $user && $user->id === $ownable->$ownerField;
return $allPermission || ($isOwner && $ownPermission);
}
// Handle abnormal create jointPermissions
if ($action === 'create') {
$action = $permission;
}
$hasAccess = $this->entityRestrictionQuery($baseQuery, $action)->count() > 0;
$this->clean();
return $hasAccess;
}
/**
* Checks if a user has the given permission for any items in the system.
* Can be passed an entity instance to filter on a specific type.
*/
public function checkUserHasPermissionOnAnything(string $permission, ?string $entityClass = null): bool
{
$userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray();
$userId = $this->currentUser()->id;
$permissionQuery = JointPermission::query()
->where('action', '=', $permission)
->whereIn('role_id', $userRoleIds)
->where(function (Builder $query) use ($userId) {
$this->addJointHasPermissionCheck($query, $userId);
});
if (!is_null($entityClass)) {
$entityInstance = app($entityClass);
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
}
$hasPermission = $permissionQuery->count() > 0;
$this->clean();
return $hasPermission;
}
/**
* The general query filter to remove all entities
* that the current user does not have access to.
*/
protected function entityRestrictionQuery(Builder $query, string $action): Builder
{
$q = $query->where(function ($parentQuery) use ($action) {
$parentQuery->whereHas('jointPermissions', function ($permissionQuery) use ($action) {
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
->where('action', '=', $action)
->where(function (Builder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
});
});
$this->clean();
return $q;
}
/**
* Limited the given entity query so that the query will only
* return items that the user has permission for the given ability.
*/
public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
{
$this->clean();
return $query->where(function (Builder $parentQuery) use ($ability) {
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
->where('action', '=', $ability)
->where(function (Builder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
});
});
}
/**
* Extend the given page query to ensure draft items are not visible
* unless created by the given user.
*/
public function enforceDraftVisibilityOnQuery(Builder $query): Builder
{
return $query->where(function (Builder $query) {
$query->where('draft', '=', false)
->orWhere(function (Builder $query) {
$query->where('draft', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
});
}
/**
* Add restrictions for a generic entity.
*/
public function enforceEntityRestrictions(Entity $entity, Builder $query, string $action = 'view'): Builder
{
if ($entity instanceof Page) {
// Prevent drafts being visible to others.
$this->enforceDraftVisibilityOnQuery($query);
}
return $this->entityRestrictionQuery($query, $action);
}
/**
* 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 filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
{
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$pageMorphClass = (new Page())->getMorphClass();
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
/** @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'])
->where('joint_permissions.action', '=', $action)
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
})->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);
});
});
$this->clean();
return $q;
}
/**
* Add conditions to a query to filter the selection to related entities
* where view permissions are granted.
*/
public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder
{
$fullEntityIdColumn = $tableName . '.' . $entityIdColumn;
$instance = new $entityClass();
$morphClass = $instance->getMorphClass();
$existsQuery = function ($permissionQuery) use ($fullEntityIdColumn, $morphClass) {
/** @var Builder $permissionQuery */
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $fullEntityIdColumn)
->where('joint_permissions.entity_type', '=', $morphClass)
->where('joint_permissions.action', '=', 'view')
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
};
$q = $query->where(function ($query) use ($existsQuery, $fullEntityIdColumn) {
$query->whereExists($existsQuery)
->orWhere($fullEntityIdColumn, '=', 0);
});
if ($instance instanceof Page) {
// Prevent visibility of non-owned draft pages
$q->whereExists(function (QueryBuilder $query) use ($fullEntityIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullEntityIdColumn)
->where(function (QueryBuilder $query) {
$query->where('pages.draft', '=', false)
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
});
});
}
$this->clean();
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);
});
}
/**
* Get the current user.
*/
private function currentUser(): User
{
if (is_null($this->currentUserModel)) {
$this->currentUserModel = user();
}
return $this->currentUserModel;
}
/**
* Clean the cached user elements.
*/
private function clean(): void
{
$this->currentUserModel = null;
$this->userRoles = null;
}
}

View File

@@ -11,20 +11,15 @@ use Illuminate\Database\Eloquent\Collection;
class PermissionsRepo
{
protected $permission;
protected $role;
protected $permissionService;
protected JointPermissionBuilder $permissionBuilder;
protected $systemRoles = ['admin', 'public'];
/**
* PermissionsRepo constructor.
*/
public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService)
public function __construct(JointPermissionBuilder $permissionBuilder)
{
$this->permission = $permission;
$this->role = $role;
$this->permissionService = $permissionService;
$this->permissionBuilder = $permissionBuilder;
}
/**
@@ -32,7 +27,7 @@ class PermissionsRepo
*/
public function getAllRoles(): Collection
{
return $this->role->all();
return Role::query()->get();
}
/**
@@ -40,7 +35,7 @@ class PermissionsRepo
*/
public function getAllRolesExcept(Role $role): Collection
{
return $this->role->where('id', '!=', $role->id)->get();
return Role::query()->where('id', '!=', $role->id)->get();
}
/**
@@ -48,7 +43,7 @@ class PermissionsRepo
*/
public function getRoleById($id): Role
{
return $this->role->newQuery()->findOrFail($id);
return Role::query()->findOrFail($id);
}
/**
@@ -56,13 +51,14 @@ class PermissionsRepo
*/
public function saveNewRole(array $roleData): Role
{
$role = $this->role->newInstance($roleData);
$role = new Role($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->save();
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$this->assignRolePermissions($role, $permissions);
$this->permissionService->buildJointPermissionForRole($role);
$this->permissionBuilder->rebuildForRole($role);
Activity::add(ActivityType::ROLE_CREATE, $role);
return $role;
@@ -74,8 +70,7 @@ class PermissionsRepo
*/
public function updateRole($roleId, array $roleData)
{
/** @var Role $role */
$role = $this->role->newQuery()->findOrFail($roleId);
$role = $this->getRoleById($roleId);
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
if ($role->system_name === 'admin') {
@@ -93,12 +88,13 @@ class PermissionsRepo
$role->fill($roleData);
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
$role->save();
$this->permissionService->buildJointPermissionForRole($role);
$this->permissionBuilder->rebuildForRole($role);
Activity::add(ActivityType::ROLE_UPDATE, $role);
}
/**
* Assign an list of permission names to an role.
* Assign a list of permission names to a role.
*/
protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
{
@@ -106,7 +102,7 @@ class PermissionsRepo
$permissionNameArray = array_values($permissionNameArray);
if ($permissionNameArray) {
$permissions = $this->permission->newQuery()
$permissions = RolePermission::query()
->whereIn('name', $permissionNameArray)
->pluck('id')
->toArray();
@@ -126,8 +122,7 @@ class PermissionsRepo
*/
public function deleteRole($roleId, $migrateRoleId)
{
/** @var Role $role */
$role = $this->role->newQuery()->findOrFail($roleId);
$role = $this->getRoleById($roleId);
// Prevent deleting admin role or default registration role.
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
@@ -137,14 +132,14 @@ class PermissionsRepo
}
if ($migrateRoleId) {
$newRole = $this->role->newQuery()->find($migrateRoleId);
$newRole = Role::query()->find($migrateRoleId);
if ($newRole) {
$users = $role->users()->pluck('id')->toArray();
$newRole->users()->sync($users);
}
}
$this->permissionService->deleteJointPermissionsForRole($role);
$role->jointPermissions()->delete();
Activity::add(ActivityType::ROLE_DELETE, $role);
$role->delete();
}

View File

@@ -0,0 +1,13 @@
<?php
namespace BookStack\Auth\Permissions;
class SimpleEntityData
{
public int $id;
public string $type;
public bool $restricted;
public int $owned_by;
public ?int $book_id;
public ?int $chapter_id;
}

View File

@@ -0,0 +1,39 @@
<?php
namespace BookStack\Auth\Queries;
use BookStack\Auth\User;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Get all the users with their permissions in a paginated format.
* Note: Due to the use of email search this should only be used when
* user is assumed to be trusted. (Admin users).
* Email search can be abused to extract email addresses.
*/
class AllUsersPaginatedAndSorted
{
/**
* @param array{sort: string, order: string, search: string} $sortData
*/
public function run(int $count, array $sortData): LengthAwarePaginator
{
$sort = $sortData['sort'];
$query = User::query()->select(['*'])
->scopes(['withLastActivityAt'])
->with(['roles', 'avatar'])
->withCount('mfaValues')
->orderBy($sort, $sortData['order']);
if ($sortData['search']) {
$term = '%' . $sortData['search'] . '%';
$query->where(function ($query) use ($term) {
$query->where('name', 'like', $term)
->orWhere('email', 'like', $term);
});
}
return $query->paginate($count);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace BookStack\Auth\Queries;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
/**
* Get asset created counts for the given user.
*/
class UserContentCounts
{
/**
* @return array{pages: int, chapters: int, books: int, shelves: int}
*/
public function run(User $user): array
{
$createdBy = ['created_by' => $user->id];
return [
'pages' => Page::visible()->where($createdBy)->count(),
'chapters' => Chapter::visible()->where($createdBy)->count(),
'books' => Book::visible()->where($createdBy)->count(),
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace BookStack\Auth\Queries;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
/**
* Get the recently created content for the provided user.
*/
class UserRecentlyCreatedContent
{
/**
* @return array{pages: Collection, chapters: Collection, books: Collection, shelves: Collection}
*/
public function run(User $user, int $count): array
{
$query = function (Builder $query) use ($user, $count) {
return $query->orderBy('created_at', 'desc')
->where('created_by', '=', $user->id)
->take($count)
->get();
};
return [
'pages' => $query(Page::visible()->where('draft', '=', false)),
'chapters' => $query(Chapter::visible()),
'books' => $query(Book::visible()),
'shelves' => $query(Bookshelf::visible()),
];
}
}

View File

@@ -28,6 +28,8 @@ class Role extends Model implements Loggable
protected $fillable = ['display_name', 'description', 'external_auth_id'];
protected $hidden = ['pivot'];
/**
* The roles that belong to the role.
*/

View File

@@ -72,22 +72,25 @@ 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',
'created_at', 'updated_at', 'image_id', 'roles', 'avatar', 'user_id',
];
/**
* This holds the user's permissions when loaded.
*
* @var ?Collection
*/
protected $permissions;
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.
*
* @var null|User
*/
protected static $defaultUser = null;
protected static ?User $defaultUser = null;
/**
* Returns the default public user.
@@ -165,7 +168,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}
/**
* Get all permissions belonging to a the current user.
* Get all permissions belonging to the current user.
*/
protected function permissions(): Collection
{
@@ -235,12 +238,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

@@ -2,30 +2,29 @@
namespace BookStack\Auth;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Facades\Activity;
use BookStack\Uploads\UserAvatars;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class UserRepo
{
protected $userAvatar;
protected UserAvatars $userAvatar;
protected UserInviteService $inviteService;
/**
* UserRepo constructor.
*/
public function __construct(UserAvatars $userAvatar)
public function __construct(UserAvatars $userAvatar, UserInviteService $inviteService)
{
$this->userAvatar = $userAvatar;
$this->inviteService = $inviteService;
}
/**
@@ -53,70 +52,164 @@ class UserRepo
}
/**
* Get all the users with their permissions.
* Create a new basic instance of user with the given pre-validated data.
*
* @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data
*/
public function getAllUsers(): Collection
public function createWithoutActivity(array $data, bool $emailConfirmed = false): User
{
return User::query()->with('roles', 'avatar')->orderBy('name', 'asc')->get();
}
$user = new User();
$user->name = $data['name'];
$user->email = $data['email'];
$user->password = bcrypt(empty($data['password']) ? Str::random(32) : $data['password']);
$user->email_confirmed = $emailConfirmed;
$user->external_auth_id = $data['external_auth_id'] ?? '';
/**
* Get all the users with their permissions in a paginated format.
* Note: Due to the use of email search this should only be used when
* user is assumed to be trusted. (Admin users).
* Email search can be abused to extract email addresses.
*/
public function getAllUsersPaginatedAndSorted(int $count, array $sortData): LengthAwarePaginator
{
$sort = $sortData['sort'];
$user->refreshSlug();
$user->save();
$query = User::query()->select(['*'])
->scopes(['withLastActivityAt'])
->with(['roles', 'avatar'])
->withCount('mfaValues')
->orderBy($sort, $sortData['order']);
if ($sortData['search']) {
$term = '%' . $sortData['search'] . '%';
$query->where(function ($query) use ($term) {
$query->where('name', 'like', $term)
->orWhere('email', 'like', $term);
});
if (!empty($data['language'])) {
setting()->putUser($user, 'language', $data['language']);
}
return $query->paginate($count);
}
if (isset($data['roles'])) {
$this->setUserRoles($user, $data['roles']);
}
/**
* Creates a new user and attaches a role to them.
*/
public function registerNew(array $data, bool $emailConfirmed = false): User
{
$user = $this->create($data, $emailConfirmed);
$user->attachDefaultRole();
$this->downloadAndAssignUserAvatar($user);
return $user;
}
/**
* Assign a user to a system-level role.
* As per "createWithoutActivity" but records a "create" activity.
*
* @throws NotFoundException
* @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data
*/
public function attachSystemRole(User $user, string $systemRoleName)
public function create(array $data, bool $sendInvite = false): User
{
$role = Role::getSystemRole($systemRoleName);
if (is_null($role)) {
throw new NotFoundException("Role '{$systemRoleName}' not found");
$user = $this->createWithoutActivity($data, true);
if ($sendInvite) {
$this->inviteService->sendInvitation($user);
}
Activity::add(ActivityType::USER_CREATE, $user);
return $user;
}
/**
* Update the given user with the given data.
*
* @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string} $data
*
* @throws UserUpdateException
*/
public function update(User $user, array $data, bool $manageUsersAllowed): User
{
if (!empty($data['name'])) {
$user->name = $data['name'];
$user->refreshSlug();
}
if (!empty($data['email']) && $manageUsersAllowed) {
$user->email = $data['email'];
}
if (!empty($data['external_auth_id']) && $manageUsersAllowed) {
$user->external_auth_id = $data['external_auth_id'];
}
if (isset($data['roles']) && $manageUsersAllowed) {
$this->setUserRoles($user, $data['roles']);
}
if (!empty($data['password'])) {
$user->password = bcrypt($data['password']);
}
if (!empty($data['language'])) {
setting()->putUser($user, 'language', $data['language']);
}
$user->save();
Activity::add(ActivityType::USER_UPDATE, $user);
return $user;
}
/**
* Remove the given user from storage, Delete all related content.
*
* @throws Exception
*/
public function destroy(User $user, ?int $newOwnerId = null)
{
$this->ensureDeletable($user);
$user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
$user->delete();
// Delete user profile images
$this->userAvatar->destroyAllForUser($user);
if (!empty($newOwnerId)) {
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {
$this->migrateOwnership($user, $newOwner);
}
}
Activity::add(ActivityType::USER_DELETE, $user);
}
/**
* @throws NotifyException
*/
protected function ensureDeletable(User $user): void
{
if ($this->isOnlyAdmin($user)) {
throw new NotifyException(trans('errors.users_cannot_delete_only_admin'), $user->getEditUrl());
}
if ($user->system_name === 'public') {
throw new NotifyException(trans('errors.users_cannot_delete_guest'), $user->getEditUrl());
}
}
/**
* Migrate ownership of items in the system from one user to another.
*/
protected function migrateOwnership(User $fromUser, User $toUser)
{
$entities = (new EntityProvider())->all();
foreach ($entities as $instance) {
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
->update(['owned_by' => $toUser->id]);
}
}
/**
* Get an avatar image for a user and set it as their avatar.
* Returns early if avatars disabled or not set in config.
*/
protected function downloadAndAssignUserAvatar(User $user): void
{
try {
$this->userAvatar->fetchAndAssignToUser($user);
} catch (Exception $e) {
Log::error('Failed to save user avatar image');
}
$user->attachRole($role);
}
/**
* Checks if the give user is the only admin.
*/
public function isOnlyAdmin(User $user): bool
protected function isOnlyAdmin(User $user): bool
{
if (!$user->hasSystemRole('admin')) {
return false;
@@ -135,7 +228,7 @@ class UserRepo
*
* @throws UserUpdateException
*/
public function setUserRoles(User $user, array $roles)
protected function setUserRoles(User $user, array $roles)
{
if ($this->demotingLastAdmin($user, $roles)) {
throw new UserUpdateException(trans('errors.role_cannot_remove_only_admin'), $user->getEditUrl());
@@ -159,117 +252,4 @@ class UserRepo
return false;
}
/**
* Create a new basic instance of user.
*/
public function create(array $data, bool $emailConfirmed = false): User
{
$details = [
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
'email_confirmed' => $emailConfirmed,
'external_auth_id' => $data['external_auth_id'] ?? '',
];
$user = new User();
$user->forceFill($details);
$user->refreshSlug();
$user->save();
return $user;
}
/**
* Remove the given user from storage, Delete all related content.
*
* @throws Exception
*/
public function destroy(User $user, ?int $newOwnerId = null)
{
$user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
$user->delete();
// Delete user profile images
$this->userAvatar->destroyAllForUser($user);
if (!empty($newOwnerId)) {
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {
$this->migrateOwnership($user, $newOwner);
}
}
}
/**
* Migrate ownership of items in the system from one user to another.
*/
protected function migrateOwnership(User $fromUser, User $toUser)
{
$entities = (new EntityProvider())->all();
foreach ($entities as $instance) {
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
->update(['owned_by' => $toUser->id]);
}
}
/**
* Get the recently created content for this given user.
*/
public function getRecentlyCreated(User $user, int $count = 20): array
{
$query = function (Builder $query) use ($user, $count) {
return $query->orderBy('created_at', 'desc')
->where('created_by', '=', $user->id)
->take($count)
->get();
};
return [
'pages' => $query(Page::visible()->where('draft', '=', false)),
'chapters' => $query(Chapter::visible()),
'books' => $query(Book::visible()),
'shelves' => $query(Bookshelf::visible()),
];
}
/**
* Get asset created counts for the give user.
*/
public function getAssetCounts(User $user): array
{
$createdBy = ['created_by' => $user->id];
return [
'pages' => Page::visible()->where($createdBy)->count(),
'chapters' => Chapter::visible()->where($createdBy)->count(),
'books' => Book::visible()->where($createdBy)->count(),
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
];
}
/**
* Get the roles in the system that are assignable to a user.
*/
public function getAllRoles(): Collection
{
return Role::query()->orderBy('display_name', 'asc')->get();
}
/**
* Get an avatar image for a user and set it as their avatar.
* Returns early if avatars disabled or not set in config.
*/
public function downloadAndAssignUserAvatar(User $user): void
{
try {
$this->userAvatar->fetchAndAssignToUser($user);
} catch (Exception $e) {
Log::error('Failed to save user avatar image');
}
}
}

View File

@@ -22,7 +22,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
@@ -57,6 +57,17 @@ return [
// Space separated if multiple. BookStack host domain is auto-inferred.
'iframe_hosts' => env('ALLOWED_IFRAME_HOSTS', null),
// A list of sources/hostnames that can be loaded within iframes within BookStack.
// Space separated if multiple. BookStack host domain is auto-inferred.
// Can be set to a lone "*" to allow all sources for iframe content (Not advised).
// Defaults to a set of common services.
// Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'),
// Alter the precision of IP addresses stored by BookStack.
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
// Application timezone for back-end date functions.
'timezone' => env('APP_TIMEZONE', 'UTC'),
@@ -64,7 +75,7 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'],
'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', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
// Application Fallback Locale
'fallback_locale' => 'en',
@@ -190,12 +201,9 @@ return [
// Third Party
'ImageTool' => Intervention\Image\Facades\Image::class,
'DomPDF' => Barryvdh\DomPDF\Facade::class,
'SnappyPDF' => Barryvdh\Snappy\Facades\SnappyPdf::class,
// Custom BookStack
'Activity' => BookStack\Facades\Activity::class,
'Permissions' => BookStack\Facades\Permissions::class,
'Theme' => BookStack\Facades\Theme::class,
],

View File

@@ -13,6 +13,10 @@ return [
// Options: standard, ldap, saml2, oidc
'method' => env('AUTH_METHOD', 'standard'),
// Automatically initiate login via external auth system if it's the sole auth method.
// Works with saml2 or oidc auth methods.
'auto_initiate' => env('AUTH_AUTO_INITIATE', false),
// Authentication Defaults
// This option controls the default authentication "guard" and password
// reset options for your application.

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',
@@ -15,8 +16,8 @@ $dompdfPaperSizeMap = [
return [
'show_warnings' => false, // Throw an Exception on warnings from dompdf
'orientation' => 'portrait',
'defines' => [
'options' => [
/**
* The location of the DOMPDF font directory.
*
@@ -77,15 +78,25 @@ return [
'chroot' => realpath(public_path()),
/**
* Whether to use Unicode fonts or not.
* Protocol whitelist.
*
* When set to true the PDF backend must be set to "CPDF" and fonts must be
* loaded via load_font.php.
* Protocols and PHP wrappers allowed in URIs, and the validation rules
* that determine if a resouce may be loaded. Full support is not guaranteed
* for the protocols/wrappers specified
* by this array.
*
* When enabled, dompdf can support all Unicode glyphs. Any glyphs used in a
* document must be present in your fonts, however.
* @var array
*/
'unicode_enabled' => true,
'allowed_protocols' => [
'file://' => ['rules' => []],
'http://' => ['rules' => []],
'https://' => ['rules' => []],
],
/**
* @var string
*/
'log_output_file' => null,
/**
* Whether to enable font subsetting or not.
@@ -156,6 +167,15 @@ return [
*/
'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
/**
* The default paper orientation.
*
* The orientation of the page (portrait or landscape).
*
* @var string
*/
'default_paper_orientation' => 'portrait',
/**
* The default font family.
*
@@ -258,10 +278,13 @@ return [
'enable_css_float' => true,
/**
* Use the more-than-experimental HTML5 Lib parser.
* Use the HTML5 Lib parser.
*
* @deprecated This feature is now always on in dompdf 2.x
*
* @var bool
*/
'enable_html5parser' => true,
'enable_html5_parser' => true,
],
];

View File

@@ -32,4 +32,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

@@ -119,6 +119,7 @@ return [
'ldap' => [
'server' => env('LDAP_SERVER', false),
'dump_user_details' => env('LDAP_DUMP_USER_DETAILS', false),
'dump_user_groups' => env('LDAP_DUMP_USER_GROUPS', false),
'dn' => env('LDAP_DN', false),
'pass' => env('LDAP_PASS', false),
'base_dn' => env('LDAP_BASE_DN', false),

View File

@@ -72,7 +72,7 @@ return [
// to the server if the browser has a HTTPS connection. This will keep
// the cookie from being sent to you if it can not be done securely.
'secure' => env('SESSION_SECURE_COOKIE', null)
?? Str::startsWith(env('APP_URL'), 'https:'),
?? Str::startsWith(env('APP_URL', ''), 'https:'),
// HTTP Access Only
// Setting this value to true will prevent JavaScript from accessing the

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

@@ -2,6 +2,7 @@
namespace BookStack\Console\Commands;
use BookStack\Auth\Role;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Console\Command;
@@ -84,9 +85,8 @@ class CreateAdmin extends Command
return SymfonyCommand::FAILURE;
}
$user = $this->userRepo->create($validator->validated());
$this->userRepo->attachSystemRole($user, 'admin');
$this->userRepo->downloadAndAssignUserAvatar($user);
$user = $this->userRepo->createWithoutActivity($validator->validated());
$user->attachRole(Role::getSystemRole('admin'));
$user->email_confirmed = true;
$user->save();

View File

@@ -15,8 +15,6 @@ class DeleteUsers extends Command
*/
protected $signature = 'bookstack:delete-users';
protected $user;
protected $userRepo;
/**
@@ -26,9 +24,8 @@ class DeleteUsers extends Command
*/
protected $description = 'Delete users that are not "admin" or system users';
public function __construct(User $user, UserRepo $userRepo)
public function __construct(UserRepo $userRepo)
{
$this->user = $user;
$this->userRepo = $userRepo;
parent::__construct();
}
@@ -38,8 +35,8 @@ class DeleteUsers extends Command
$confirm = $this->ask('This will delete all users from the system that are not "admin" or system users. Are you sure you want to continue? (Type "yes" to continue)');
$numDeleted = 0;
if (strtolower(trim($confirm)) === 'yes') {
$totalUsers = $this->user->count();
$users = $this->user->where('system_name', '=', null)->with('roles')->get();
$totalUsers = User::query()->count();
$users = User::query()->whereNull('system_name')->with('roles')->get();
foreach ($users as $user) {
if ($user->hasSystemRole('admin')) {
// don't delete users with "admin" role

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

@@ -2,8 +2,9 @@
namespace BookStack\Console\Commands;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\Permissions\JointPermissionBuilder;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RegeneratePermissions extends Command
{
@@ -21,19 +22,14 @@ class RegeneratePermissions extends Command
*/
protected $description = 'Regenerate all system permissions';
/**
* The service to handle the permission system.
*
* @var PermissionService
*/
protected $permissionService;
protected JointPermissionBuilder $permissionBuilder;
/**
* Create a new command instance.
*/
public function __construct(PermissionService $permissionService)
public function __construct(JointPermissionBuilder $permissionBuilder)
{
$this->permissionService = $permissionService;
$this->permissionBuilder = $permissionBuilder;
parent::__construct();
}
@@ -44,15 +40,17 @@ class RegeneratePermissions extends Command
*/
public function handle()
{
$connection = \DB::getDefaultConnection();
if ($this->option('database') !== null) {
\DB::setDefaultConnection($this->option('database'));
$this->permissionService->setConnection(\DB::connection($this->option('database')));
$connection = DB::getDefaultConnection();
if ($this->option('database')) {
DB::setDefaultConnection($this->option('database'));
}
$this->permissionService->buildJointPermissions();
$this->permissionBuilder->rebuildForAll();
\DB::setDefaultConnection($connection);
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

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

@@ -86,15 +86,11 @@ class Bookshelf extends Entity implements HasCoverImage
*/
public function coverImageTypeKey(): string
{
return 'cover_shelf';
return 'cover_bookshelf';
}
/**
* Check if this shelf contains the given book.
*
* @param Book $book
*
* @return bool
*/
public function contains(Book $book): bool
{
@@ -103,8 +99,6 @@ class Bookshelf extends Entity implements HasCoverImage
/**
* Add a book to the end of this shelf.
*
* @param Book $book
*/
public function appendBook(Book $book)
{

View File

@@ -10,10 +10,16 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property int $deleted_by
* @property string $deletable_type
* @property int $deletable_id
* @property Deletable $deletable
*/
class Deletion extends Model implements Loggable
{
protected $hidden = [];
/**
* Get the related deletable record.
*/

View File

@@ -9,15 +9,18 @@ use BookStack\Actions\Tag;
use BookStack\Actions\View;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Entities\Tools\SearchIndex;
use BookStack\Auth\Permissions\JointPermissionBuilder;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Facades\Permissions;
use BookStack\Interfaces\Deletable;
use BookStack\Interfaces\Favouritable;
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;
@@ -43,7 +46,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property Collection $tags
*
* @method static Entity|Builder visible()
* @method static Entity|Builder hasPermission(string $permission)
* @method static Builder withLastView()
* @method static Builder withViewCount()
*/
@@ -68,15 +70,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function scopeVisible(Builder $query): Builder
{
return $this->scopeHasPermission($query, 'view');
}
/**
* Scope the query to those entities that the current user has the given permission for.
*/
public function scopeHasPermission(Builder $query, string $permission)
{
return Permissions::restrictEntityQuery($query, $permission);
return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
}
/**
@@ -210,6 +204,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'.
@@ -284,8 +294,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function rebuildPermissions()
{
/** @noinspection PhpUnhandledExceptionInspection */
Permissions::buildJointPermissionsForEntity(clone $this);
app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this);
}
/**
@@ -293,7 +302,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function indexForSearch()
{
app(SearchIndex::class)->indexEntity(clone $this);
app()->make(SearchIndex::class)->indexEntity(clone $this);
}
/**
@@ -301,7 +310,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function refreshSlug(): string
{
$this->slug = app(SlugGenerator::class)->generate($this);
$this->slug = app()->make(SlugGenerator::class)->generate($this);
return $this->slug;
}

View File

@@ -2,27 +2,31 @@
namespace BookStack\Entities\Models;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Tools\PageContent;
use BookStack\Facades\Permissions;
use BookStack\Uploads\Attachment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* Class Page.
*
* @property int $chapter_id
* @property string $html
* @property string $markdown
* @property string $text
* @property bool $template
* @property bool $draft
* @property int $revision_count
* @property Chapter $chapter
* @property Collection $attachments
* @property int $chapter_id
* @property string $html
* @property string $markdown
* @property string $text
* @property bool $template
* @property bool $draft
* @property int $revision_count
* @property string $editor
* @property Chapter $chapter
* @property Collection $attachments
* @property Collection $revisions
* @property PageRevision $currentRevision
*/
class Page extends BookChild
{
@@ -47,7 +51,7 @@ class Page extends BookChild
*/
public function scopeVisible(Builder $query): Builder
{
$query = Permissions::enforceDraftVisibilityOnQuery($query);
$query = app()->make(PermissionApplicator::class)->restrictDraftsOnPageQuery($query);
return parent::scopeVisible($query);
}
@@ -82,6 +86,19 @@ class Page extends BookChild
->orderBy('id', 'desc');
}
/**
* Get the current revision for the page if existing.
*
* @return PageRevision|null
*/
public function currentRevision(): HasOne
{
return $this->hasOne(PageRevision::class)
->where('type', '=', 'version')
->orderBy('created_at', 'desc')
->orderBy('id', 'desc');
}
/**
* Get all revision instances assigned to this page.
* Includes all types of revisions.
@@ -117,16 +134,6 @@ class Page extends BookChild
return url('/' . implode('/', $parts));
}
/**
* Get the current revision for the page if existing.
*
* @return PageRevision|null
*/
public function getCurrentRevision()
{
return $this->revisions()->first();
}
/**
* Get this page for JSON display.
*/

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;
@@ -10,7 +11,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Class PageRevision.
*
* @property mixed $id
* @property int $page_id
* @property string $name
* @property string $slug
* @property string $book_slug
* @property int $created_by
@@ -20,13 +23,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $summary
* @property string $markdown
* @property string $html
* @property string $text
* @property int $revision_number
* @property Page $page
* @property-read ?User $createdBy
*/
class PageRevision extends Model
class PageRevision extends Model implements Loggable
{
protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
protected $fillable = ['name', 'text', 'summary'];
protected $hidden = ['html', 'markdown', 'restricted', 'text'];
/**
* Get the user that created the page revision.
@@ -79,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

@@ -2,14 +2,14 @@
namespace BookStack\Entities\Queries;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\EntityProvider;
abstract class EntityQuery
{
protected function permissionService(): PermissionService
protected function permissionService(): PermissionApplicator
{
return app()->make(PermissionService::class);
return app()->make(PermissionApplicator::class);
}
protected function entityProvider(): EntityProvider

View File

@@ -7,10 +7,10 @@ use Illuminate\Support\Facades\DB;
class Popular extends EntityQuery
{
public function run(int $count, int $page, array $filterModels = null, string $action = 'view')
public function run(int $count, int $page, array $filterModels = null)
{
$query = $this->permissionService()
->filterRestrictedEntityRelations(View::query(), 'views', 'viewable_id', 'viewable_type', $action)
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');

View File

@@ -14,12 +14,11 @@ class RecentlyViewed extends EntityQuery
return collect();
}
$query = $this->permissionService()->filterRestrictedEntityRelations(
$query = $this->permissionService()->restrictEntityRelationQuery(
View::query(),
'views',
'viewable_id',
'viewable_type',
'view'
'viewable_type'
)
->orderBy('views.updated_at', 'desc')
->where('user_id', '=', user()->id);

View File

@@ -15,7 +15,7 @@ class TopFavourites extends EntityQuery
}
$query = $this->permissionService()
->filterRestrictedEntityRelations(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type', 'view')
->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type')
->select('favourites.*')
->leftJoin('views', function (JoinClause $join) {
$join->on('favourites.favouritable_id', '=', 'views.viewable_id');

View File

@@ -6,18 +6,21 @@ 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;
class BaseRepo
{
protected $tagRepo;
protected $imageRepo;
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();
}
@@ -58,10 +64,15 @@ class BaseRepo
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
$entity->touch();
}
$entity->rebuildPermissions();
$entity->indexForSearch();
if ($oldUrl !== $entity->getUrl()) {
$this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);
}
}
/**
@@ -75,8 +86,9 @@ class BaseRepo
public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false)
{
if ($coverImage) {
$imageType = $entity->coverImageTypeKey();
$this->imageRepo->destroyImage($entity->cover);
$image = $this->imageRepo->saveNew($coverImage, 'cover_book', $entity->id, 512, 512, true);
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
$entity->cover()->associate($image);
$entity->save();
}

View File

@@ -91,6 +91,7 @@ class BookRepo
{
$book = new Book();
$this->baseRepo->create($book, $input);
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
Activity::add(ActivityType::BOOK_CREATE, $book);
return $book;
@@ -102,6 +103,11 @@ class BookRepo
public function update(Book $book, array $input): Book
{
$this->baseRepo->update($book, $input);
if (array_key_exists('image', $input)) {
$this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
}
Activity::add(ActivityType::BOOK_UPDATE, $book);
return $book;

View File

@@ -6,12 +6,10 @@ use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
class BookshelfRepo
@@ -89,6 +87,7 @@ class BookshelfRepo
{
$shelf = new Bookshelf();
$this->baseRepo->create($shelf, $input);
$this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
$this->updateBooks($shelf, $bookIds);
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
@@ -106,14 +105,17 @@ class BookshelfRepo
$this->updateBooks($shelf, $bookIds);
}
if (array_key_exists('image', $input)) {
$this->baseRepo->updateCoverImage($shelf, $input['image'], $input['image'] === null);
}
Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf);
return $shelf;
}
/**
* Update which books are assigned to this shelf by
* syncing the given book ids.
* Update which books are assigned to this shelf by syncing the given book ids.
* Function ensures the books are visible to the current user and existing.
*/
protected function updateBooks(Bookshelf $shelf, array $bookIds)
@@ -132,24 +134,13 @@ class BookshelfRepo
$shelf->books()->sync($syncData);
}
/**
* Update the given shelf cover image, or clear it.
*
* @throws ImageUploadException
* @throws Exception
*/
public function updateCoverImage(Bookshelf $shelf, ?UploadedFile $coverImage, bool $removeImage = false)
{
$this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage);
}
/**
* 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']);
$shelfBooks = $shelf->books()->get(['id', 'restricted', 'owned_by']);
$updatedBookCount = 0;
/** @var Book $book */

View File

@@ -0,0 +1,36 @@
<?php
namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Facades\Activity;
class DeletionRepo
{
private TrashCan $trashCan;
public function __construct(TrashCan $trashCan)
{
$this->trashCan = $trashCan;
}
public function restore(int $id): int
{
/** @var Deletion $deletion */
$deletion = Deletion::query()->findOrFail($id);
Activity::add(ActivityType::RECYCLE_BIN_RESTORE, $deletion);
return $this->trashCan->restoreFromDeletion($deletion);
}
public function destroy(int $id): int
{
/** @var Deletion $deletion */
$deletion = Deletion::query()->findOrFail($id);
Activity::add(ActivityType::RECYCLE_BIN_DESTROY, $deletion);
return $this->trashCan->destroyFromDeletion($deletion);
}
}

View File

@@ -10,25 +10,37 @@ use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditorData;
use BookStack\Entities\Tools\TrashCan;
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;
}
/**
@@ -38,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) {
@@ -69,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;
}
@@ -111,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();
@@ -122,9 +125,7 @@ class PageRepo
*/
public function getUserDraft(Page $page): ?PageRevision
{
$revision = $this->getUserDraftQuery($page)->first();
return $revision;
return $this->revisionRepo->getLatestDraftForCurrentUser($page);
}
/**
@@ -164,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);
@@ -188,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'] ?? '');
@@ -202,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);
@@ -217,33 +218,25 @@ class PageRepo
}
$pageContent = new PageContent($page);
if (!empty($input['markdown'] ?? '')) {
$currentEditor = $page->editor ?: PageEditorData::getSystemDefaultEditor();
$newEditor = $currentEditor;
$haveInput = isset($input['markdown']) || isset($input['html']);
$inputEmpty = empty($input['markdown']) && empty($input['html']);
if ($haveInput && $inputEmpty) {
$pageContent->setNewHTML('');
} elseif (!empty($input['markdown']) && is_string($input['markdown'])) {
$newEditor = 'markdown';
$pageContent->setNewMarkdown($input['markdown']);
} elseif (isset($input['html'])) {
$newEditor = 'wysiwyg';
$pageContent->setNewHTML($input['html']);
}
}
/**
* Saves a page revision into the system.
*/
protected function savePageRevision(Page $page, string $summary = null): PageRevision
{
$revision = new PageRevision($page->getAttributes());
$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;
if ($newEditor !== $currentEditor && userCan('editor-change')) {
$page->editor = $newEditor;
}
}
/**
@@ -260,10 +253,15 @@ class PageRepo
return $page;
}
// Otherwise save the data to a revision
$draft = $this->getPageRevisionToUpdate($page);
// Otherwise, save the data to a revision
$draft = $this->revisionRepo->getNewDraftForCurrentUser($page);
$draft->fill($input);
if (setting('app-editor') !== 'markdown') {
if (!empty($input['markdown'])) {
$draft->markdown = $input['markdown'];
$draft->html = '';
} else {
$draft->html = $input['html'];
$draft->markdown = '';
}
@@ -290,6 +288,7 @@ class PageRepo
*/
public function restoreRevision(Page $page, int $revisionId): Page
{
$oldUrl = $page->getUrl();
$page->revision_count++;
/** @var PageRevision $revision */
@@ -308,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;
}
@@ -368,65 +373,6 @@ class PageRepo
return $parentClass::visible()->where('id', '=', $entityId)->first();
}
/**
* Change the page's parent to the given entity.
*/
protected function changeParent(Page $page, Entity $parent)
{
$book = ($parent instanceof Chapter) ? $parent->book : $parent;
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0;
$page->save();
if ($page->book->id !== $book->id) {
$page->changeBook($book->id);
}
$page->load('book');
$book->rebuildPermissions();
}
/**
* 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.
*/
@@ -442,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

@@ -16,25 +16,10 @@ use Illuminate\Http\UploadedFile;
class Cloner
{
/**
* @var PageRepo
*/
protected $pageRepo;
/**
* @var ChapterRepo
*/
protected $chapterRepo;
/**
* @var BookRepo
*/
protected $bookRepo;
/**
* @var ImageService
*/
protected $imageService;
protected PageRepo $pageRepo;
protected ChapterRepo $chapterRepo;
protected BookRepo $bookRepo;
protected ImageService $imageService;
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
{
@@ -50,11 +35,8 @@ class Cloner
public function clonePage(Page $original, Entity $parent, string $newName): Page
{
$copyPage = $this->pageRepo->getNewDraftPage($parent);
$pageData = $original->getAttributes();
// Update name & tags
$pageData = $this->entityToInputData($original);
$pageData['name'] = $newName;
$pageData['tags'] = $this->entityTagsToInputArray($original);
return $this->pageRepo->publishDraft($copyPage, $pageData);
}
@@ -65,9 +47,8 @@ class Cloner
*/
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
{
$chapterDetails = $original->getAttributes();
$chapterDetails = $this->entityToInputData($original);
$chapterDetails['name'] = $newName;
$chapterDetails['tags'] = $this->entityTagsToInputArray($original);
$copyChapter = $this->chapterRepo->create($chapterDetails, $parent);
@@ -87,9 +68,8 @@ class Cloner
*/
public function cloneBook(Book $original, string $newName): Book
{
$bookDetails = $original->getAttributes();
$bookDetails = $this->entityToInputData($original);
$bookDetails['name'] = $newName;
$bookDetails['tags'] = $this->entityTagsToInputArray($original);
$copyBook = $this->bookRepo->create($bookDetails);
@@ -104,26 +84,48 @@ class Cloner
}
}
if ($original->cover) {
try {
$tmpImgFile = tmpfile();
$uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile);
$this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false);
} catch (\Exception $exception) {
}
return $copyBook;
}
/**
* Convert an entity to a raw data array of input data.
*
* @return array<string, mixed>
*/
public function entityToInputData(Entity $entity): array
{
$inputData = $entity->getAttributes();
$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;
}
return $copyBook;
return $inputData;
}
/**
* Copy the permission settings from the source entity to the target entity.
*/
public function copyEntityPermissions(Entity $sourceEntity, Entity $targetEntity): void
{
$targetEntity->restricted = $sourceEntity->restricted;
$permissions = $sourceEntity->permissions()->get(['role_id', 'action'])->toArray();
$targetEntity->permissions()->delete();
$targetEntity->permissions()->createMany($permissions);
$targetEntity->rebuildPermissions();
}
/**
* Convert an image instance to an UploadedFile instance to mimic
* a file being uploaded.
*/
protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile
protected function imageToUploadedFile(Image $image): ?UploadedFile
{
$imgData = $this->imageService->getImageData($image);
$tmpImgFilePath = stream_get_meta_data($tmpFile)['uri'];
$tmpImgFilePath = tempnam(sys_get_temp_dir(), 'bs_cover_clone_');
file_put_contents($tmpImgFilePath, $imgData);
return new UploadedFile($tmpImgFilePath, basename($image->path));

View File

@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Uploads\ImageService;
use BookStack\Util\CspService;
use DOMDocument;
use DOMElement;
use DOMXPath;
@@ -15,16 +16,18 @@ use Throwable;
class ExportFormatter
{
protected $imageService;
protected $pdfGenerator;
protected ImageService $imageService;
protected PdfGenerator $pdfGenerator;
protected CspService $cspService;
/**
* ExportService constructor.
*/
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator)
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService)
{
$this->imageService = $imageService;
$this->pdfGenerator = $pdfGenerator;
$this->cspService = $cspService;
}
/**
@@ -36,9 +39,10 @@ class ExportFormatter
public function pageToContainedHtml(Page $page)
{
$page->html = (new PageContent($page))->render();
$pageHtml = view('pages.export', [
'page' => $page,
'format' => 'html',
$pageHtml = view('exports.page', [
'page' => $page,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
])->render();
return $this->containHtml($pageHtml);
@@ -55,10 +59,11 @@ class ExportFormatter
$pages->each(function ($page) {
$page->html = (new PageContent($page))->render();
});
$html = view('chapters.export', [
'chapter' => $chapter,
'pages' => $pages,
'format' => 'html',
$html = view('exports.chapter', [
'chapter' => $chapter,
'pages' => $pages,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
])->render();
return $this->containHtml($html);
@@ -72,10 +77,11 @@ class ExportFormatter
public function bookToContainedHtml(Book $book)
{
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('books.export', [
$html = view('exports.book', [
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
])->render();
return $this->containHtml($html);
@@ -89,7 +95,7 @@ class ExportFormatter
public function pageToPdf(Page $page)
{
$page->html = (new PageContent($page))->render();
$html = view('pages.export', [
$html = view('exports.page', [
'page' => $page,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
@@ -110,7 +116,7 @@ class ExportFormatter
$page->html = (new PageContent($page))->render();
});
$html = view('chapters.export', [
$html = view('exports.chapter', [
'chapter' => $chapter,
'pages' => $pages,
'format' => 'pdf',
@@ -128,7 +134,7 @@ class ExportFormatter
public function bookToPdf(Book $book)
{
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('books.export', [
$html = view('exports.book', [
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'pdf',
@@ -147,10 +153,31 @@ class ExportFormatter
{
$html = $this->containHtml($html);
$html = $this->replaceIframesWithLinks($html);
$html = $this->openDetailElements($html);
return $this->pdfGenerator->fromHtml($html);
}
/**
* Within the given HTML content, Open any detail blocks.
*/
protected function openDetailElements(string $html): string
{
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$details = $xPath->query('//details');
/** @var DOMElement $detail */
foreach ($details as $detail) {
$detail->setAttribute('open', 'open');
}
return $doc->saveHTML();
}
/**
* Within the given HTML content, replace any iframe elements
* with anchor links within paragraph blocks.
@@ -208,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;
@@ -221,7 +248,6 @@ class ExportFormatter
}
}
// Replace any relative links with system domain
return $htmlContent;
}
@@ -299,7 +325,7 @@ class ExportFormatter
$text .= $this->pageToMarkdown($page) . "\n\n";
}
return $text;
return trim($text);
}
/**
@@ -311,12 +337,12 @@ class ExportFormatter
$text = '# ' . $book->name . "\n\n";
foreach ($bookTree as $bookChild) {
if ($bookChild instanceof Chapter) {
$text .= $this->chapterToMarkdown($bookChild);
$text .= $this->chapterToMarkdown($bookChild) . "\n\n";
} else {
$text .= $this->pageToMarkdown($bookChild);
$text .= $this->pageToMarkdown($bookChild) . "\n\n";
}
}
return $text;
return trim($text);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Facades\Activity;
class HierarchyTransformer
{
protected BookRepo $bookRepo;
protected BookshelfRepo $shelfRepo;
protected Cloner $cloner;
protected TrashCan $trashCan;
public function __construct(BookRepo $bookRepo, BookshelfRepo $shelfRepo, Cloner $cloner, TrashCan $trashCan)
{
$this->bookRepo = $bookRepo;
$this->shelfRepo = $shelfRepo;
$this->cloner = $cloner;
$this->trashCan = $trashCan;
}
/**
* Transform a chapter into a book.
* Does not check permissions, check before calling.
*/
public function transformChapterToBook(Chapter $chapter): Book
{
$inputData = $this->cloner->entityToInputData($chapter);
$book = $this->bookRepo->create($inputData);
$this->cloner->copyEntityPermissions($chapter, $book);
/** @var Page $page */
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
$page->changeBook($book->id);
}
$this->trashCan->destroyEntity($chapter);
Activity::add(ActivityType::BOOK_CREATE_FROM_CHAPTER, $book);
return $book;
}
/**
* Transform a book into a shelf.
* Does not check permissions, check before calling.
*/
public function transformBookToShelf(Book $book): Bookshelf
{
$inputData = $this->cloner->entityToInputData($book);
$shelf = $this->shelfRepo->create($inputData, []);
$this->cloner->copyEntityPermissions($book, $shelf);
$shelfBookSyncData = [];
/** @var Chapter $chapter */
foreach ($book->chapters as $index => $chapter) {
$newBook = $this->transformChapterToBook($chapter);
$shelfBookSyncData[$newBook->id] = ['order' => $index];
if (!$newBook->restricted) {
$this->cloner->copyEntityPermissions($shelf, $newBook);
}
}
if ($book->directPages->count() > 0) {
$book->name .= ' ' . trans('entities.pages');
$shelfBookSyncData[$book->id] = ['order' => count($shelfBookSyncData) + 1];
$book->save();
} else {
$this->trashCan->destroyEntity($book);
}
$shelf->books()->sync($shelfBookSyncData);
Activity::add(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $shelf);
return $shelf;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\HTMLToMarkdown\Converter\ConverterInterface;
use League\HTMLToMarkdown\ElementInterface;
class CheckboxConverter implements ConverterInterface
{
public function convert(ElementInterface $element): string
{
if (strtolower($element->getAttribute('type')) === 'checkbox') {
$isChecked = $element->getAttribute('checked') === 'checked';
return $isChecked ? ' [x] ' : ' [ ] ';
}
return $element->getValue();
}
/**
* @return string[]
*/
public function getSupportedTags(): array
{
return ['input'];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\HTMLToMarkdown\Converter\DivConverter;
use League\HTMLToMarkdown\ElementInterface;
class CustomDivConverter extends DivConverter
{
public function convert(ElementInterface $element): string
{
// Clean up draw.io diagrams
$drawIoDiagram = $element->getAttribute('drawio-diagram');
if ($drawIoDiagram) {
return "<div drawio-diagram=\"{$drawIoDiagram}\">{$element->getValue()}</div>\n\n";
}
return parent::convert($element);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\HTMLToMarkdown\Converter\ImageConverter;
use League\HTMLToMarkdown\ElementInterface;
class CustomImageConverter extends ImageConverter
{
public function convert(ElementInterface $element): string
{
$parent = $element->getParent();
// Remain as HTML if within diagram block.
$withinDrawing = $parent && !empty($parent->getAttribute('drawio-diagram'));
if ($withinDrawing) {
$src = e($element->getAttribute('src'));
$alt = e($element->getAttribute('alt'));
return "<img src=\"{$src}\" alt=\"{$alt}\"/>";
}
return parent::convert($element);
}
}

View File

@@ -9,7 +9,7 @@ class CustomParagraphConverter extends ParagraphConverter
{
public function convert(ElementInterface $element): string
{
$class = $element->getAttribute('class');
$class = e($element->getAttribute('class'));
if (strpos($class, 'callout') !== false) {
return "<{$element->getTagName()} class=\"{$class}\">{$element->getValue()}</{$element->getTagName()}>\n\n";
}

View File

@@ -5,12 +5,10 @@ namespace BookStack\Entities\Tools\Markdown;
use League\HTMLToMarkdown\Converter\BlockquoteConverter;
use League\HTMLToMarkdown\Converter\CodeConverter;
use League\HTMLToMarkdown\Converter\CommentConverter;
use League\HTMLToMarkdown\Converter\DivConverter;
use League\HTMLToMarkdown\Converter\EmphasisConverter;
use League\HTMLToMarkdown\Converter\HardBreakConverter;
use League\HTMLToMarkdown\Converter\HeaderConverter;
use League\HTMLToMarkdown\Converter\HorizontalRuleConverter;
use League\HTMLToMarkdown\Converter\ImageConverter;
use League\HTMLToMarkdown\Converter\LinkConverter;
use League\HTMLToMarkdown\Converter\ListBlockConverter;
use League\HTMLToMarkdown\Converter\ListItemConverter;
@@ -21,7 +19,7 @@ use League\HTMLToMarkdown\HtmlConverter;
class HtmlToMarkdown
{
protected $html;
protected string $html;
public function __construct(string $html)
{
@@ -75,18 +73,20 @@ class HtmlToMarkdown
$environment->addConverter(new BlockquoteConverter());
$environment->addConverter(new CodeConverter());
$environment->addConverter(new CommentConverter());
$environment->addConverter(new DivConverter());
$environment->addConverter(new CustomDivConverter());
$environment->addConverter(new EmphasisConverter());
$environment->addConverter(new HardBreakConverter());
$environment->addConverter(new HeaderConverter());
$environment->addConverter(new HorizontalRuleConverter());
$environment->addConverter(new ImageConverter());
$environment->addConverter(new CustomImageConverter());
$environment->addConverter(new LinkConverter());
$environment->addConverter(new ListBlockConverter());
$environment->addConverter(new ListItemConverter());
$environment->addConverter(new CustomParagraphConverter());
$environment->addConverter(new PreformattedConverter());
$environment->addConverter(new TextConverter());
$environment->addConverter(new CheckboxConverter());
$environment->addConverter(new SpacedTagFallbackConverter());
return $environment;
}

View File

@@ -0,0 +1,35 @@
<?php
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\Extension\Table\TableExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
class MarkdownToHtml
{
protected string $markdown;
public function __construct(string $markdown)
{
$this->markdown = $markdown;
}
public function convert(): string
{
$environment = Environment::createCommonMarkEnvironment();
$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);
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
return $converter->convertToHtml($this->markdown);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\HTMLToMarkdown\Converter\ConverterInterface;
use League\HTMLToMarkdown\ElementInterface;
/**
* For certain defined tags, add additional spacing upon the retained HTML content
* to separate it out from anything that may be markdown soon afterwards or within.
*/
class SpacedTagFallbackConverter implements ConverterInterface
{
public function convert(ElementInterface $element): string
{
return \html_entity_decode($element->getChildrenAsString()) . "\n\n";
}
public function getSupportedTags(): array
{
return ['summary', 'iframe'];
}
}

View File

@@ -3,8 +3,7 @@
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\CustomListItemRenderer;
use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
@@ -17,15 +16,10 @@ use DOMNode;
use DOMNodeList;
use DOMXPath;
use Illuminate\Support\Str;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
class PageContent
{
protected $page;
protected Page $page;
/**
* PageContent constructor.
@@ -53,28 +47,11 @@ class PageContent
{
$markdown = $this->extractBase64ImagesFromMarkdown($markdown);
$this->page->markdown = $markdown;
$html = $this->markdownToHtml($markdown);
$html = (new MarkdownToHtml($markdown))->convert();
$this->page->html = $this->formatHtml($html);
$this->page->text = $this->toPlainText();
}
/**
* Convert the given Markdown content to a HTML string.
*/
protected function markdownToHtml(string $markdown): string
{
$environment = Environment::createCommonMarkEnvironment();
$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);
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
return $converter->convertToHtml($markdown);
}
/**
* Convert all base64 image data to saved images.
*/
@@ -239,6 +216,9 @@ class PageContent
$html .= $doc->saveHTML($childNode);
}
// Perform required string-level tweaks
$html = str_replace(' ', '&nbsp;', $html);
return $html;
}
@@ -394,23 +374,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;

View File

@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Builder;
class PageEditActivity
{
protected $page;
protected Page $page;
/**
* PageEditActivity constructor.
@@ -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

@@ -0,0 +1,115 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
class PageEditorData
{
protected Page $page;
protected PageRepo $pageRepo;
protected string $requestedEditor;
protected array $viewData;
protected array $warnings;
public function __construct(Page $page, PageRepo $pageRepo, string $requestedEditor)
{
$this->page = $page;
$this->pageRepo = $pageRepo;
$this->requestedEditor = $requestedEditor;
$this->viewData = $this->build();
}
public function getViewData(): array
{
return $this->viewData;
}
public function getWarnings(): array
{
return $this->warnings;
}
protected function build(): array
{
$page = clone $this->page;
$isDraft = boolval($this->page->draft);
$templates = $this->pageRepo->getTemplates(10);
$draftsEnabled = auth()->check();
$isDraftRevision = false;
$this->warnings = [];
$editActivity = new PageEditActivity($page);
if ($editActivity->hasActiveEditing()) {
$this->warnings[] = $editActivity->activeEditingMessage();
}
// Check for a current draft version for this user
$userDraft = $this->pageRepo->getUserDraft($page);
if ($userDraft !== null) {
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
$isDraftRevision = true;
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
}
$editorType = $this->getEditorType($page);
$this->updateContentForEditor($page, $editorType);
return [
'page' => $page,
'book' => $page->book,
'isDraft' => $isDraft,
'isDraftRevision' => $isDraftRevision,
'draftsEnabled' => $draftsEnabled,
'templates' => $templates,
'editor' => $editorType,
];
}
protected function updateContentForEditor(Page $page, string $editorType): void
{
$isHtml = !empty($page->html) && empty($page->markdown);
// HTML to markdown-clean conversion
if ($editorType === 'markdown' && $isHtml && $this->requestedEditor === 'markdown-clean') {
$page->markdown = (new HtmlToMarkdown($page->html))->convert();
}
// Markdown to HTML conversion if we don't have HTML
if ($editorType === 'wysiwyg' && !$isHtml) {
$page->html = (new MarkdownToHtml($page->markdown))->convert();
}
}
/**
* Get the type of editor to show for editing the given page.
* Defaults based upon the current content of the page otherwise will fall back
* to system default but will take a requested type (if provided) if permissions allow.
*/
protected function getEditorType(Page $page): string
{
$editorType = $page->editor ?: self::getSystemDefaultEditor();
// Use requested editor if valid and if we have permission
$requestedType = explode('-', $this->requestedEditor)[0];
if (($requestedType === 'markdown' || $requestedType === 'wysiwyg') && userCan('editor-change')) {
$editorType = $requestedType;
}
return $editorType;
}
/**
* Get the configured system default editor.
*/
public static function getSystemDefaultEditor(): string
{
return setting('app-editor') === 'markdown' ? 'markdown' : 'wysiwyg';
}
}

View File

@@ -2,7 +2,7 @@
namespace BookStack\Entities\Tools;
use Barryvdh\DomPDF\Facade as DomPDF;
use Barryvdh\DomPDF\Facade\Pdf as DomPDF;
use Barryvdh\Snappy\Facades\SnappyPdf;
class PdfGenerator

View File

@@ -20,6 +20,7 @@ class ShelfContext
return null;
}
/** @var Bookshelf $shelf */
$shelf = Bookshelf::visible()->find($contextBookshelfId);
$shelfContainsBook = $shelf && $shelf->contains($book);

View File

@@ -344,7 +344,7 @@ class TrashCan
*
* @throws Exception
*/
protected function destroyEntity(Entity $entity): int
public function destroyEntity(Entity $entity): int
{
if ($entity instanceof Page) {
return $this->destroyPage($entity);
@@ -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

@@ -21,6 +21,7 @@ class Handler extends ExceptionHandler
*/
protected $dontReport = [
NotFoundException::class,
StoppedAuthenticationException::class,
];
/**
@@ -101,6 +102,10 @@ class Handler extends ExceptionHandler
$code = $e->status;
}
if (method_exists($e, 'getStatus')) {
$code = $e->getStatus();
}
$responseData['error']['code'] = $code;
return new JsonResponse($responseData, $code, $headers);

View File

@@ -3,24 +3,29 @@
namespace BookStack\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
class JsonDebugException extends Exception
{
protected $data;
protected array $data;
/**
* JsonDebugException constructor.
*/
public function __construct($data)
public function __construct(array $data)
{
$this->data = $data;
parent::__construct();
}
/**
* Covert this exception into a response.
* Convert this exception into a response.
* We add a manual data conversion to UTF8 to ensure any binary data is presentable as a JSON string.
*/
public function render()
public function render(): JsonResponse
{
return response()->json($this->data);
$cleaned = mb_convert_encoding($this->data, 'UTF-8');
return response()->json($cleaned);
}
}

View File

@@ -9,17 +9,24 @@ class NotifyException extends Exception implements Responsable
{
public $message;
public $redirectLocation;
protected $status;
/**
* NotifyException constructor.
*/
public function __construct(string $message, string $redirectLocation = '/')
public function __construct(string $message, string $redirectLocation = '/', int $status = 500)
{
$this->message = $message;
$this->redirectLocation = $redirectLocation;
$this->status = $status;
parent::__construct();
}
/**
* Get the desired status code for this exception.
*/
public function getStatus(): int
{
return $this->status;
}
/**
* Send the response for this type of exception.
*
@@ -29,6 +36,11 @@ class NotifyException extends Exception implements Responsable
{
$message = $this->getMessage();
// Front-end JSON handling. API-side handling managed via handler.
if ($request->wantsJson()) {
return response()->json(['error' => $message], 403);
}
if (!empty($message)) {
session()->flash('error', $message);
}

View File

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

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

@@ -1,18 +0,0 @@
<?php
namespace BookStack\Facades;
use Illuminate\Support\Facades\Facade;
class Permissions extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'permissions';
}
}

View File

@@ -15,10 +15,14 @@ abstract class ApiController extends Controller
* Provide a paginated listing JSON response in a standard format
* taking into account any pagination parameters passed by the user.
*/
protected function apiListingResponse(Builder $query, array $fields): JsonResponse
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = []): JsonResponse
{
$listing = new ListingResponseBuilder($query, request(), $fields);
foreach ($modifiers as $modifier) {
$listing->modifyResults($modifier);
}
return $listing->toResponse();
}
@@ -26,7 +30,7 @@ abstract class ApiController extends Controller
* Get the validation rules for this controller.
* Defaults to a $rules property but can be a rules() method.
*/
public function getValdationRules(): array
public function getValidationRules(): array
{
if (method_exists($this, 'rules')) {
return $this->rules();

View File

@@ -87,14 +87,33 @@ class AttachmentApiController extends ApiController
'markdown' => $attachment->markdownLink(),
]);
if (!$attachment->external) {
$attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
$attachment->setAttribute('content', base64_encode($attachmentContents));
} else {
// Simply return a JSON response of the attachment for link-based attachments
if ($attachment->external) {
$attachment->setAttribute('content', $attachment->path);
return response()->json($attachment);
}
return response()->json($attachment);
// Build and split our core JSON, at point of content.
$splitter = 'CONTENT_SPLIT_LOCATION_' . time() . '_' . rand(1, 40000);
$attachment->setAttribute('content', $splitter);
$json = $attachment->toJson();
$jsonParts = explode($splitter, $json);
// Get a stream for the file data from storage
$stream = $this->attachmentService->streamAttachmentFromStorage($attachment);
return response()->stream(function () use ($jsonParts, $stream) {
// Output the pre-content JSON data
echo $jsonParts[0];
// Stream out our attachment data as base64 content
stream_filter_append($stream, 'convert.base64-encode', STREAM_FILTER_READ);
fpassthru($stream);
fclose($stream);
// Output our post-content JSON data
echo $jsonParts[1];
}, 200, ['Content-Type' => 'application/json']);
}
/**

View File

@@ -11,19 +11,6 @@ class BookApiController extends ApiController
{
protected $bookRepo;
protected $rules = [
'create' => [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
],
];
public function __construct(BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
@@ -37,19 +24,21 @@ class BookApiController extends ApiController
$books = Book::visible();
return $this->apiListingResponse($books, [
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', 'image_id',
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
]);
}
/**
* Create a new book in the system.
* The cover image of a book can be set by sending a file via an 'image' property within a 'multipart/form-data' request.
* If the 'image' property is null then the book cover image will be removed.
*
* @throws ValidationException
*/
public function create(Request $request)
{
$this->checkPermission('book-create-all');
$requestData = $this->validate($request, $this->rules['create']);
$requestData = $this->validate($request, $this->rules()['create']);
$book = $this->bookRepo->create($requestData);
@@ -68,6 +57,8 @@ class BookApiController extends ApiController
/**
* Update the details of a single book.
* The cover image of a book can be set by sending a file via an 'image' property within a 'multipart/form-data' request.
* If the 'image' property is null then the book cover image will be removed.
*
* @throws ValidationException
*/
@@ -76,7 +67,7 @@ class BookApiController extends ApiController
$book = Book::visible()->findOrFail($id);
$this->checkOwnablePermission('book-update', $book);
$requestData = $this->validate($request, $this->rules['update']);
$requestData = $this->validate($request, $this->rules()['update']);
$book = $this->bookRepo->update($book, $requestData);
return response()->json($book);
@@ -97,4 +88,22 @@ class BookApiController extends ApiController
return response('', 204);
}
protected function rules(): array
{
return [
'create' => [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
],
];
}
}

View File

@@ -26,7 +26,7 @@ class BookExportApiController extends ApiController
$book = Book::visible()->findOrFail($id);
$pdfContent = $this->exportFormatter->bookToPdf($book);
return $this->downloadResponse($pdfContent, $book->slug . '.pdf');
return $this->download()->directly($pdfContent, $book->slug . '.pdf');
}
/**
@@ -39,7 +39,7 @@ class BookExportApiController extends ApiController
$book = Book::visible()->findOrFail($id);
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
return $this->downloadResponse($htmlContent, $book->slug . '.html');
return $this->download()->directly($htmlContent, $book->slug . '.html');
}
/**
@@ -50,7 +50,7 @@ class BookExportApiController extends ApiController
$book = Book::visible()->findOrFail($id);
$textContent = $this->exportFormatter->bookToPlainText($book);
return $this->downloadResponse($textContent, $book->slug . '.txt');
return $this->download()->directly($textContent, $book->slug . '.txt');
}
/**
@@ -61,6 +61,6 @@ class BookExportApiController extends ApiController
$book = Book::visible()->findOrFail($id);
$markdown = $this->exportFormatter->bookToMarkdown($book);
return $this->downloadResponse($markdown, $book->slug . '.md');
return $this->download()->directly($markdown, $book->slug . '.md');
}
}

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