Compare commits

..

220 Commits

Author SHA1 Message Date
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
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
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
d11144d9e2 Updated version and assets for release v21.12.5 2022-02-06 15:49:23 +00:00
Dan Brown
f96b0ea5f3 Merge branch 'development' into release 2022-02-06 15:48:55 +00:00
Dan Brown
b4e29d2b7d New Crowdin updates (#3225) 2022-02-06 15:46:28 +00:00
Dan Brown
2732d8961f Merge branch 'fix-code-block-linefeed' into development 2022-02-06 15:19:52 +00:00
Dan Brown
b2f863e1f1 WYSIWG: Improved handling of cross-block code block creation
- Updated code content to get specific text selection instead of using
  node-based handling which could return the whole document when
  multiple top-level nodes were in selection.
- Simplified how code gets applied into the page to not be node based
  but use native editor methods to replace the selection. Allows
  creation from half-way through a block.

Tested on chrome+Firefox on Fedora 35.
Builds upon changes in #3246.
For #3200.
2022-02-06 15:19:18 +00:00
Dan Brown
1df7497c09 Added missing validation.file message
- Included test to cover
- Also applied StyleCI fixes

Closes #3248
2022-02-06 14:48:33 +00:00
Dan Brown
d29a2a647a Prevented PCRE limit issues in markdown base64 extraction
For #3249
2022-02-06 07:51:38 +00:00
Dan Brown
43f32f6d5a Added attachment API file size limit test
Created while testing for #3248, Was not something that's currently
failing within BookStack but will still add for coverage.
2022-02-06 05:05:17 +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
julesdevops
049d6ba5b2 fix(wysiwyg): preserves line feeds in code block mode 2022-02-05 10:28:44 +01: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
Dan Brown
815f8d79ed Updated version and assets for release v21.12.4 2022-02-01 11:52:24 +00:00
Dan Brown
b62dab32e0 Merge branch 'development' into release 2022-02-01 11:51:48 +00:00
Dan Brown
9d15688a43 Applied latest styleci changes 2022-02-01 11:49:30 +00:00
Dan Brown
033b163675 New Crowdin updates (#3214)
* New translations auth.php (Spanish)

* New translations auth.php (Estonian)

* New translations entities.php (Estonian)

* New translations common.php (French)

* New translations common.php (Indonesian)

* New translations common.php (Turkish)

* New translations common.php (Ukrainian)

* New translations common.php (Chinese Simplified)

* New translations common.php (Chinese Traditional)

* New translations common.php (Vietnamese)

* New translations common.php (Portuguese, Brazilian)

* New translations common.php (Persian)

* New translations common.php (Slovenian)

* New translations common.php (Spanish, Argentina)

* New translations common.php (Croatian)

* New translations common.php (Estonian)

* New translations common.php (Latvian)

* New translations common.php (Bosnian)

* New translations common.php (Norwegian Bokmal)

* New translations common.php (Swedish)

* New translations common.php (Slovak)

* New translations common.php (Spanish)

* New translations common.php (Hebrew)

* New translations common.php (Arabic)

* New translations common.php (Bulgarian)

* New translations common.php (Catalan)

* New translations common.php (Czech)

* New translations common.php (Danish)

* New translations common.php (German)

* New translations common.php (Hungarian)

* New translations common.php (Russian)

* New translations common.php (Italian)

* New translations common.php (Japanese)

* New translations common.php (Korean)

* New translations common.php (Lithuanian)

* New translations common.php (Dutch)

* New translations common.php (Polish)

* New translations common.php (Portuguese)

* New translations common.php (German Informal)

* New translations common.php (Spanish)

* New translations common.php (Italian)

* New translations settings.php (Italian)

* New translations common.php (Spanish, Argentina)
2022-02-01 11:48:29 +00:00
Dan Brown
6eadf3efb3 Added language select to the user create form
- Updated user invite to take language from user.
- Added tests to cover.
- Added page/tab title to user create view.

For #2576 and #2408
2022-01-31 22:15:21 +00:00
Dan Brown
f83cc83877 Added external-auth-id option to create-admin command
- Added tests to cover.
- Refactored some existing testing.
- Requires password or external_auth_id to be provided. Defaults to
  password.
- Randomly sets password to 32 digit random chars if external_auth_id
  provided instead.

For #3222
2022-01-31 20:43:41 +00:00
Dan Brown
17215431ca Fixed default registration role display options
- This also allows an admin to choose not to have a default role.
- Also applied latest styleCI fixes.

For #3220
2022-01-31 14:16:56 +00:00
Dan Brown
90c543064b Merge branch 'development' of github.com:BookStackApp/BookStack into development 2022-01-30 17:41:16 +00:00
Dan Brown
a709fd04b5 Added option to configure PDF export paper size
For #995
2022-01-30 17:40:42 +00:00
StyleCI Bot
4a1d060eb9 Apply fixes from StyleCI 2022-01-30 16:44:51 +00:00
Dan Brown
e17cdab420 Updated default branch name references 2022-01-30 16:33:03 +00:00
Dan Brown
2d074caf72 Merge pull request #3210 from Julesdevops/simple-503-error-file
Massively simplify the 503 error view
2022-01-30 16:24:24 +00:00
julesdevops
99202b3bb8 fix(503): massively simplify the 503 error view
This view was relying on too much app logic, which could lead to errors
when rendering it.
2022-01-29 10:56:13 +01:00
Dan Brown
73eac83afe Fixed OIDC JWT key parsing in microsoft environments
Made existence of 'alg' optional when JWK array set so we instead infer
it as RSA256 if not existing.

Fixes #3206
2022-01-28 14:00:55 +00:00
Dan Brown
c11f795c1d Added cloudabove sponsor logo 2022-01-26 20:45:14 +00:00
Dan Brown
262f863981 Updated version and assets for release v21.12.3 2022-01-24 22:49:42 +00:00
Dan Brown
a4c94390a1 Merge branch 'master' into release 2022-01-24 22:49:31 +00:00
Dan Brown
7e6e1fca76 Fixed test broken by PdfGenerator changes 2022-01-24 22:24:41 +00:00
Dan Brown
aaa2205df1 Refreshed markdown cm instance layout on size change
Intended to fix positioning quirks caused by changing codemirror
instance size when you have lines that wrap and cause line height
changes. Often caused by editor toolbox expand/collapse.

This adds a debounced resize observer to refresh editor layout on size
change.
Also tweaks toolbox expand/collapse to more consistently set aria
attribute.

For #3186
2022-01-24 22:08:36 +00:00
Dan Brown
4aed3f8558 Patched gallery duplication on multi-image upload
Quick patch to clear the gallery display when getting the first page.
Duplication of the galler was occuring due to the mulitple upload events
loading the gallery mulitple times while only clearing the existing
gallery at the start of all refreshes.

A bit flashy in terms of user experience, as there will still be
mulitple load/clear events but fixes the duplication. Could be done more
elegently in future by communicating up image upload counts.

For #3160
2022-01-24 21:38:11 +00:00
Dan Brown
7b4086107c Added parent context to recently updated items
- Includes tests to cover
For #3183
2022-01-24 21:21:30 +00:00
Dan Brown
585bd0cc45 Updated translator attribution and StyleCI changes 2022-01-24 20:55:03 +00:00
Dan Brown
f18e2784be New Crowdin updates (#3158)
* New translations activities.php (Slovak)

* New translations activities.php (Slovak)

* New translations common.php (Russian)

* New translations settings.php (Russian)

* New translations common.php (Japanese)

* New translations settings.php (Japanese)

* New translations activities.php (Persian)

* New translations auth.php (Persian)

* New translations auth.php (Persian)

* New translations entities.php (Persian)

* New translations common.php (Persian)

* New translations auth.php (Persian)

* New translations entities.php (Persian)

* New translations settings.php (Persian)

* New translations entities.php (Persian)

* New translations validation.php (Persian)

* New translations settings.php (Persian)

* New translations settings.php (Spanish, Argentina)

* New translations common.php (Spanish, Argentina)

* New translations settings.php (Spanish, Argentina)

* New translations activities.php (Spanish, Argentina)

* New translations entities.php (Spanish, Argentina)

* New translations entities.php (Persian)

* New translations settings.php (Persian)

* New translations entities.php (Persian)

* New translations errors.php (Persian)

* New translations settings.php (French)

* New translations common.php (French)

* New translations settings.php (French)

* New translations entities.php (Persian)

* New translations settings.php (Persian)

* New translations entities.php (Persian)

* New translations errors.php (Persian)
2022-01-24 20:53:36 +00:00
Dan Brown
f88e6d1520 Updated HTMLDiff package to address multibtye issue
Addresses potential issue when using multibyte characters.
Couple of other packages seemed to have updates also since earlier.

For #3170
2022-01-24 20:27:14 +00:00
Dan Brown
872961ef7c Updated npm and php dependancies 2022-01-24 18:53:28 +00:00
Dan Brown
bbd8d63652 Merge pull request #3179 from Julesdevops/atomic-user-creation
When creating a user, do not persist the user on invitation sending failure
2022-01-24 18:48:00 +00:00
Dan Brown
af39ff15ac Merge branch 'show_more_informations_on_recently_updated_pages' 2022-01-24 18:23:47 +00:00
Dan Brown
aae3cd69d7 Added test to cover PR #3177 2022-01-24 18:23:16 +00:00
Dan Brown
2d3df955ae Merge branch 'master' of github.com:BookStackApp/BookStack 2022-01-24 17:26:17 +00:00
Dan Brown
8b5747eae2 Further adjusted linked image sizes on PDF export
Further fixes for #3120, Adds DOMPDF specific adjustments to prevent
full width linked images being cut-off as per last tweak.
This does not fix usage in smaller cases (tables) but tested on
master DOMPDF branch shows that will likely be fixed in next DOMPDF
upstream release.
DOMPDF fixes would break WKHTMLTOPDF presentation so system updated
to conditionally apply styles.
2022-01-24 17:24:00 +00:00
Dan Brown
6c699f7fab Merge pull request #3193 from Julesdevops/xdebug-docker-compose-setup
chore(dev): add xdebug support for docker setup
2022-01-24 16:11:29 +00:00
julesdevops
ac6eceb0e5 doc(dev): add xdebug informations 2022-01-23 14:26:01 +01:00
julesdevops
a2a2f3a4dd chore(dev): add xdebug support for docker setup 2022-01-22 17:43:29 +01:00
julesdevops
6db64763fe enh(recently updated): show updatedBy and updated_at 2022-01-19 21:49:45 +01:00
julesdevops
c9beacbfbf fix(User Creation): do not persist the user if invitation fails
- Wrap the user creation process in a transaction
- Add test
2022-01-19 20:46:38 +01: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
639 changed files with 23856 additions and 11771 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
@@ -216,6 +223,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/
@@ -266,7 +274,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
@@ -297,6 +305,11 @@ RECYCLE_BIN_LIFETIME=30
# Maximum file size, in megabytes, that can be uploaded to the system.
FILE_UPLOAD_SIZE_LIMIT=50
# Export Page Size
# Primarily used to determine page size of PDF exports.
# Can be 'a4' or 'letter'.
EXPORT_PAGE_SIZE=a4
# Allow <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false
@@ -319,6 +332,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

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]: "
labels: [":earth_africa: Translations"]
assignees:
- ssddanbrown

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

@@ -158,14 +158,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
@@ -211,3 +211,35 @@ Mundo Racional (ismael.mesquita) :: Portuguese, Brazilian
Zarik (3apuk) :: Russian
Ali Shaatani (a.shaatani) :: Arabic
ChacMaster :: Portuguese, Brazilian
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

View File

@@ -1,19 +1,14 @@
name: phpstan
on:
push:
branches-ignore:
- l10n_master
pull_request:
branches-ignore:
- l10n_master
on: [push, pull_request]
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.3']
php: ['7.4']
steps:
- uses: actions/checkout@v1

View File

@@ -1,19 +1,14 @@
name: phpunit
on:
push:
branches-ignore:
- l10n_master
pull_request:
branches-ignore:
- l10n_master
on: [push, pull_request]
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.3', '7.4', '8.0', '8.1']
php: ['7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1

View File

@@ -1,19 +1,14 @@
name: test-migrations
on:
push:
branches-ignore:
- l10n_master
pull_request:
branches-ignore:
- l10n_master
on: [push, pull_request]
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.3', '7.4', '8.0', '8.1']
php: ['7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1

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

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

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

@@ -60,8 +60,11 @@ class OidcJwtSigningKey
*/
protected function loadFromJwkArray(array $jwk)
{
if ($jwk['alg'] !== 'RS256') {
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}");
// 'alg' is optional for a JWK, but we will still attempt to validate if
// it exists otherwise presume it will be compatible.
$alg = $jwk['alg'] ?? null;
if ($jwk['kty'] !== 'RSA' || !(is_null($alg) || $alg === 'RS256')) {
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
}
if (empty($jwk['use'])) {

View File

@@ -164,7 +164,9 @@ class OidcProviderSettings
protected function filterKeys(array $keys): array
{
return array_filter($keys, function (array $key) {
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256';
$alg = $key['alg'] ?? null;
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
});
}

View File

@@ -7,14 +7,12 @@ 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\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;
@@ -25,9 +23,9 @@ use function url;
*/
class OidcService
{
protected $registrationService;
protected $loginService;
protected $httpClient;
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected HttpClient $httpClient;
/**
* OpenIdService constructor.
@@ -42,6 +40,8 @@ class OidcService
/**
* Initiate an authorization flow.
*
* @throws OidcException
*
* @return array{url: string, state: string}
*/
public function login(): array
@@ -57,14 +57,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 +79,7 @@ class OidcService
}
/**
* @throws OidcIssuerDiscoveryException
* @throws ClientExceptionInterface
* @throws OidcException
*/
protected function getProviderSettings(): OidcProviderSettings
{
@@ -100,7 +100,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();
@@ -161,9 +165,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 +185,28 @@ 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']
);
if ($user === null) {
throw new OpenIdConnectException(trans('errors.oidc_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
try {
$user = $this->registrationService->findOrRegister(
$userDetails['name'],
$userDetails['email'],
$userDetails['external_id']
);
} catch (UserRegistrationException $exception) {
throw new OidcException($exception->getMessage());
}
$this->loginService->login($user, 'oidc');

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

@@ -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,20 @@ 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 default user when loaded.
*
* @var null|User
*/
protected static $defaultUser = null;
protected static ?User $defaultUser = null;
/**
* Returns the default public user.
@@ -146,7 +144,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function attachDefaultRole(): void
{
$roleId = setting('registration-role');
$roleId = intval(setting('registration-role'));
if ($roleId && $this->roles()->where('id', '=', $roleId)->count() === 0) {
$this->roles()->attach($roleId);
}

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

@@ -57,6 +57,13 @@ 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'),
// Application timezone for back-end date functions.
'timezone' => env('APP_TIMEZONE', 'UTC'),
@@ -64,7 +71,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', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
// Application Fallback Locale
'fallback_locale' => 'en',

View File

@@ -7,6 +7,10 @@
* 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',
];
return [
@@ -150,7 +154,7 @@ return [
*
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/
'default_paper_size' => 'a4',
'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
/**
* The default font family.

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

@@ -7,6 +7,10 @@
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
$snappyPaperSizeMap = [
'a4' => 'A4',
'letter' => 'Letter',
];
return [
'pdf' => [
@@ -14,7 +18,8 @@ return [
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
'timeout' => false,
'options' => [
'outline' => true,
'outline' => true,
'page-size' => $snappyPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'A4',
],
'env' => [],
],

View File

@@ -2,9 +2,12 @@
namespace BookStack\Console\Commands;
use BookStack\Auth\Role;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Rules\Unique;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
@@ -19,7 +22,8 @@ class CreateAdmin extends Command
protected $signature = 'bookstack:create-admin
{--email= : The email address for the new admin user}
{--name= : The name of the new admin user}
{--password= : The password to assign to the new admin user}';
{--password= : The password to assign to the new admin user}
{--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)}';
/**
* The console command description.
@@ -42,28 +46,35 @@ class CreateAdmin extends Command
/**
* Execute the console command.
*
* @throws \BookStack\Exceptions\NotFoundException
* @throws NotFoundException
*
* @return mixed
*/
public function handle()
{
$details = $this->options();
$details = $this->snakeCaseOptions();
if (empty($details['email'])) {
$details['email'] = $this->ask('Please specify an email address for the new admin user');
}
if (empty($details['name'])) {
$details['name'] = $this->ask('Please specify a name for the new admin user');
}
if (empty($details['password'])) {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
if (empty($details['external_auth_id'])) {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
} else {
$details['password'] = Str::random(32);
}
}
$validator = Validator::make($details, [
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
'name' => ['required', 'min:2'],
'password' => ['required', Password::default()],
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
'name' => ['required', 'min:2'],
'password' => ['required_without:external_auth_id', Password::default()],
'external_auth_id' => ['required_without:password'],
]);
if ($validator->fails()) {
@@ -74,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();
@@ -84,4 +94,14 @@ class CreateAdmin extends Command
return SymfonyCommand::SUCCESS;
}
protected function snakeCaseOptions(): array
{
$returnOpts = [];
foreach ($this->options() as $key => $value) {
$returnOpts[str_replace('-', '_', $key)] = $value;
}
return $returnOpts;
}
}

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

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

@@ -10,19 +10,23 @@ 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
{
@@ -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

@@ -10,7 +10,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 +22,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
{
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.

View File

@@ -11,8 +11,8 @@ use Illuminate\Http\UploadedFile;
class BaseRepo
{
protected $tagRepo;
protected $imageRepo;
protected TagRepo $tagRepo;
protected ImageRepo $imageRepo;
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
{
@@ -58,6 +58,7 @@ class BaseRepo
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
$entity->touch();
}
$entity->rebuildPermissions();

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,6 +10,7 @@ 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;
@@ -217,11 +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']);
}
if ($newEditor !== $currentEditor && userCan('editor-change')) {
$page->editor = $newEditor;
}
}
/**
@@ -229,8 +244,12 @@ class PageRepo
*/
protected function savePageRevision(Page $page, string $summary = null): PageRevision
{
$revision = new PageRevision($page->getAttributes());
$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;
@@ -260,10 +279,15 @@ class PageRepo
return $page;
}
// Otherwise save the data to a revision
// Otherwise, save the data to a revision
$draft = $this->getPageRevisionToUpdate($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 = '';
}

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;
}
/**
@@ -37,8 +40,9 @@ class ExportFormatter
{
$page->html = (new PageContent($page))->render();
$pageHtml = view('pages.export', [
'page' => $page,
'format' => 'html',
'page' => $page,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
])->render();
return $this->containHtml($pageHtml);
@@ -56,9 +60,10 @@ class ExportFormatter
$page->html = (new PageContent($page))->render();
});
$html = view('chapters.export', [
'chapter' => $chapter,
'pages' => $pages,
'format' => 'html',
'chapter' => $chapter,
'pages' => $pages,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
])->render();
return $this->containHtml($html);
@@ -76,6 +81,7 @@ class ExportFormatter
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
])->render();
return $this->containHtml($html);
@@ -92,6 +98,7 @@ class ExportFormatter
$html = view('pages.export', [
'page' => $page,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
])->render();
return $this->htmlToPdf($html);
@@ -113,6 +120,7 @@ class ExportFormatter
'chapter' => $chapter,
'pages' => $pages,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
])->render();
return $this->htmlToPdf($html);
@@ -130,6 +138,7 @@ class ExportFormatter
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
])->render();
return $this->htmlToPdf($html);
@@ -144,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.
@@ -296,7 +326,7 @@ class ExportFormatter
$text .= $this->pageToMarkdown($page) . "\n\n";
}
return $text;
return trim($text);
}
/**
@@ -308,12 +338,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,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,11 +3,8 @@
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;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService;
use BookStack\Util\HtmlContentFilter;
@@ -17,15 +14,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 +45,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.
*/
@@ -109,15 +84,35 @@ class PageContent
/**
* Convert all inline base64 content to uploaded image files.
* Regex is used to locate the start of data-uri definitions then
* manual looping over content is done to parse the whole data uri.
* Attempting to capture the whole data uri using regex can cause PHP
* PCRE limits to be hit with larger, multi-MB, files.
*/
protected function extractBase64ImagesFromMarkdown(string $markdown)
{
$matches = [];
preg_match_all('/!\[.*?]\(.*?(data:image\/.*?)[)"\s]/', $markdown, $matches);
$contentLength = strlen($markdown);
$replacements = [];
preg_match_all('/!\[.*?]\(.*?(data:image\/.{1,6};base64,)/', $markdown, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[1] as $base64Match) {
$newUrl = $this->base64ImageUriToUploadedImageUrl($base64Match);
$markdown = str_replace($base64Match, $newUrl, $markdown);
foreach ($matches[1] as $base64MatchPair) {
[$dataUri, $index] = $base64MatchPair;
for ($i = strlen($dataUri) + $index; $i < $contentLength; $i++) {
$char = $markdown[$i];
if ($char === ')' || $char === ' ' || $char === "\n" || $char === '"') {
break;
}
$dataUri .= $char;
}
$newUrl = $this->base64ImageUriToUploadedImageUrl($dataUri);
$replacements[] = [$dataUri, $newUrl];
}
foreach ($replacements as [$dataUri, $newUrl]) {
$markdown = str_replace($dataUri, $newUrl, $markdown);
}
return $markdown;
@@ -219,6 +214,9 @@ class PageContent
$html .= $doc->saveHTML($childNode);
}
// Perform required string-level tweaks
$html = str_replace(' ', '&nbsp;', $html);
return $html;
}

View File

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

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

@@ -7,14 +7,15 @@ use Barryvdh\Snappy\Facades\SnappyPdf;
class PdfGenerator
{
const ENGINE_DOMPDF = 'dompdf';
const ENGINE_WKHTML = 'wkhtml';
/**
* Generate PDF content from the given HTML content.
*/
public function fromHtml(string $html): string
{
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
if ($useWKHTML) {
if ($this->getActiveEngine() === self::ENGINE_WKHTML) {
$pdf = SnappyPDF::loadHTML($html);
$pdf->setOption('print-media-type', true);
} else {
@@ -23,4 +24,15 @@ class PdfGenerator
return $pdf->output();
}
/**
* Get the currently active PDF engine.
* Returns the value of an `ENGINE_` const on this class.
*/
public function getActiveEngine(): string
{
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
return $useWKHTML ? self::ENGINE_WKHTML : self::ENGINE_DOMPDF;
}
}

View File

@@ -360,7 +360,7 @@ class SearchRunner
/** @var Connection $connection */
$connection = $query->getConnection();
$tagValue = (float) trim($connection->getPdo()->quote($tagValue), "'");
$query->whereRaw("value ${tagOperator} ${tagValue}");
$query->whereRaw("value {$tagOperator} {$tagValue}");
} else {
$query->where('value', $tagOperator, $tagValue);
}

View File

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

@@ -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,21 +11,20 @@ use Illuminate\Validation\ValidationException;
class BookshelfApiController extends ApiController
{
/**
* @var BookshelfRepo
*/
protected $bookshelfRepo;
protected BookshelfRepo $bookshelfRepo;
protected $rules = [
'create' => [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'books' => ['array'],
'tags' => ['array'],
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'books' => ['array'],
'tags' => ['array'],
],
];

View File

@@ -12,7 +12,7 @@ use Illuminate\Http\Request;
class PageApiController extends ApiController
{
protected $pageRepo;
protected PageRepo $pageRepo;
protected $rules = [
'create' => [
@@ -24,8 +24,8 @@ class PageApiController extends ApiController
'tags' => ['array'],
],
'update' => [
'book_id' => ['required', 'integer'],
'chapter_id' => ['required', 'integer'],
'book_id' => ['integer'],
'chapter_id' => ['integer'],
'name' => ['string', 'min:1', 'max:255'],
'html' => ['string'],
'markdown' => ['string'],
@@ -103,6 +103,8 @@ class PageApiController extends ApiController
*/
public function update(Request $request, string $id)
{
$requestData = $this->validate($request, $this->rules['update']);
$page = $this->pageRepo->getById($id, []);
$this->checkOwnablePermission('page-update', $page);
@@ -127,7 +129,7 @@ class PageApiController extends ApiController
}
}
$updatedPage = $this->pageRepo->update($page, $request->all());
$updatedPage = $this->pageRepo->update($page, $requestData);
return response()->json($updatedPage->forJsonDisplay());
}

View File

@@ -0,0 +1,90 @@
<?php
namespace BookStack\Http\Controllers\Api;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Repos\DeletionRepo;
use Closure;
use Illuminate\Database\Eloquent\Builder;
class RecycleBinApiController extends ApiController
{
public function __construct()
{
$this->middleware(function ($request, $next) {
$this->checkPermission('settings-manage');
$this->checkPermission('restrictions-manage-all');
return $next($request);
});
}
/**
* Get a top-level listing of the items in the recycle bin.
* The "deletable" property will reflect the main item deleted.
* For books and chapters, counts of child pages/chapters will
* be loaded within this "deletable" data.
* For chapters & pages, the parent item will be loaded within this "deletable" data.
* Requires permission to manage both system settings and permissions.
*/
public function list()
{
return $this->apiListingResponse(Deletion::query()->with('deletable'), [
'id',
'deleted_by',
'created_at',
'updated_at',
'deletable_type',
'deletable_id',
], [Closure::fromCallable([$this, 'listFormatter'])]);
}
/**
* Restore a single deletion from the recycle bin.
* Requires permission to manage both system settings and permissions.
*/
public function restore(DeletionRepo $deletionRepo, string $deletionId)
{
$restoreCount = $deletionRepo->restore(intval($deletionId));
return response()->json(['restore_count' => $restoreCount]);
}
/**
* Remove a single deletion from the recycle bin.
* Use this endpoint carefully as it will entirely remove the underlying deleted items from the system.
* Requires permission to manage both system settings and permissions.
*/
public function destroy(DeletionRepo $deletionRepo, string $deletionId)
{
$deleteCount = $deletionRepo->destroy(intval($deletionId));
return response()->json(['delete_count' => $deleteCount]);
}
/**
* Load some related details for the deletion listing.
*/
protected function listFormatter(Deletion $deletion)
{
$deletable = $deletion->deletable;
$withTrashedQuery = fn (Builder $query) => $query->withTrashed();
if ($deletable instanceof BookChild) {
$parent = $deletable->getParent();
$parent->setAttribute('type', $parent->getType());
$deletable->setRelation('parent', $parent);
}
if ($deletable instanceof Book || $deletable instanceof Chapter) {
$countsToLoad = ['pages' => $withTrashedQuery];
if ($deletable instanceof Book) {
$countsToLoad['chapters'] = $withTrashedQuery;
}
$deletable->loadCount($countsToLoad);
}
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace BookStack\Http\Controllers\Api;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserUpdateException;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Rules\Unique;
class UserApiController extends ApiController
{
protected $userRepo;
protected $fieldsToExpose = [
'email', 'created_at', 'updated_at', 'last_activity_at', 'external_auth_id',
];
public function __construct(UserRepo $userRepo)
{
$this->userRepo = $userRepo;
// Checks for all endpoints in this controller
$this->middleware(function ($request, $next) {
$this->checkPermission('users-manage');
$this->preventAccessInDemoMode();
return $next($request);
});
}
protected function rules(int $userId = null): array
{
return [
'create' => [
'name' => ['required', 'min:2'],
'email' => [
'required', 'min:2', 'email', new Unique('users', 'email'),
],
'external_auth_id' => ['string'],
'language' => ['string'],
'password' => [Password::default()],
'roles' => ['array'],
'roles.*' => ['integer'],
'send_invite' => ['boolean'],
],
'update' => [
'name' => ['min:2'],
'email' => [
'min:2',
'email',
(new Unique('users', 'email'))->ignore($userId ?? null),
],
'external_auth_id' => ['string'],
'language' => ['string'],
'password' => [Password::default()],
'roles' => ['array'],
'roles.*' => ['integer'],
],
'delete' => [
'migrate_ownership_id' => ['integer', 'exists:users,id'],
],
];
}
/**
* Get a listing of users in the system.
* Requires permission to manage users.
*/
public function list()
{
$users = User::query()->select(['*'])
->scopes('withLastActivityAt')
->with(['avatar']);
return $this->apiListingResponse($users, [
'id', 'name', 'slug', 'email', 'external_auth_id',
'created_at', 'updated_at', 'last_activity_at',
], [Closure::fromCallable([$this, 'listFormatter'])]);
}
/**
* Create a new user in the system.
* Requires permission to manage users.
*/
public function create(Request $request)
{
$data = $this->validate($request, $this->rules()['create']);
$sendInvite = ($data['send_invite'] ?? false) === true;
$user = null;
DB::transaction(function () use ($data, $sendInvite, &$user) {
$user = $this->userRepo->create($data, $sendInvite);
});
$this->singleFormatter($user);
return response()->json($user);
}
/**
* View the details of a single user.
* Requires permission to manage users.
*/
public function read(string $id)
{
$user = $this->userRepo->getById($id);
$this->singleFormatter($user);
return response()->json($user);
}
/**
* Update an existing user in the system.
* Requires permission to manage users.
*
* @throws UserUpdateException
*/
public function update(Request $request, string $id)
{
$data = $this->validate($request, $this->rules($id)['update']);
$user = $this->userRepo->getById($id);
$this->userRepo->update($user, $data, userCan('users-manage'));
$this->singleFormatter($user);
return response()->json($user);
}
/**
* Delete a user from the system.
* Can optionally accept a user id via `migrate_ownership_id` to indicate
* who should be the new owner of their related content.
* Requires permission to manage users.
*/
public function delete(Request $request, string $id)
{
$user = $this->userRepo->getById($id);
$newOwnerId = $request->get('migrate_ownership_id', null);
$this->userRepo->destroy($user, $newOwnerId);
return response('', 204);
}
/**
* Format the given user model for single-result display.
*/
protected function singleFormatter(User $user)
{
$this->listFormatter($user);
$user->load('roles:id,display_name');
$user->makeVisible(['roles']);
}
/**
* Format the given user model for a listing multi-result display.
*/
protected function listFormatter(User $user)
{
$user->makeVisible($this->fieldsToExpose);
$user->setAttribute('profile_url', $user->getProfileUrl());
$user->setAttribute('edit_url', $user->getEditUrl());
$user->setAttribute('avatar_url', $user->getAvatar());
}
}

View File

@@ -15,8 +15,8 @@ use Illuminate\Validation\ValidationException;
class AttachmentController extends Controller
{
protected $attachmentService;
protected $pageRepo;
protected AttachmentService $attachmentService;
protected PageRepo $pageRepo;
/**
* AttachmentController constructor.
@@ -230,13 +230,13 @@ class AttachmentController extends Controller
}
$fileName = $attachment->getFileName();
$attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
$attachmentStream = $this->attachmentService->streamAttachmentFromStorage($attachment);
if ($request->get('open') === 'true') {
return $this->inlineDownloadResponse($attachmentContents, $fileName);
return $this->streamedInlineDownloadResponse($attachmentStream, $fileName);
}
return $this->downloadResponse($attachmentContents, $fileName);
return $this->streamedDownloadResponse($attachmentStream, $fileName);
}
/**

View File

@@ -2,13 +2,14 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\Oidc\OidcException;
use BookStack\Auth\Access\Oidc\OidcService;
use BookStack\Http\Controllers\Controller;
use Illuminate\Http\Request;
class OidcController extends Controller
{
protected $oidcService;
protected OidcService $oidcService;
/**
* OpenIdController constructor.
@@ -24,7 +25,14 @@ class OidcController extends Controller
*/
public function login()
{
$loginDetails = $this->oidcService->login();
try {
$loginDetails = $this->oidcService->login();
} catch (OidcException $exception) {
$this->showErrorNotification($exception->getMessage());
return redirect('/login');
}
session()->flash('oidc_state', $loginDetails['state']);
return redirect($loginDetails['url']);
@@ -45,7 +53,13 @@ class OidcController extends Controller
return redirect('/login');
}
$this->oidcService->processAuthorizeResponse($request->query('code'));
try {
$this->oidcService->processAuthorizeResponse($request->query('code'));
} catch (OidcException $oidcException) {
$this->showErrorNotification($oidcException->getMessage());
return redirect('/login');
}
return redirect()->intended();
}

View File

@@ -2,16 +2,17 @@
namespace BookStack\Http\Controllers;
use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use BookStack\Util\WebSafeMimeSniffer;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller as BaseController;
use Symfony\Component\HttpFoundation\StreamedResponse;
abstract class Controller extends BaseController
{
@@ -53,14 +54,9 @@ abstract class Controller extends BaseController
*/
protected function showPermissionError()
{
if (request()->wantsJson()) {
$response = response()->json(['error' => trans('errors.permissionJson')], 403);
} else {
$response = redirect('/');
$this->showErrorNotification(trans('errors.permission'));
}
$message = request()->wantsJson() ? trans('errors.permissionJson') : trans('errors.permission');
throw new HttpResponseException($response);
throw new NotifyException($message, '/', 403);
}
/**
@@ -120,7 +116,30 @@ abstract class Controller extends BaseController
{
return response()->make($content, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . $fileName . '"',
'Content-Disposition' => 'attachment; filename="' . str_replace('"', '', $fileName) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
/**
* Create a response that forces a download, from a given stream of content.
*/
protected function streamedDownloadResponse($stream, string $fileName): StreamedResponse
{
return response()->stream(function () use ($stream) {
// End & flush the output buffer, if we're in one, otherwise we still use memory.
// Output buffer may or may not exist depending on PHP `output_buffering` setting.
// Ignore in testing since output buffers are used to gather a response.
if (!empty(ob_get_status()) && !app()->runningUnitTests()) {
ob_end_clean();
}
fpassthru($stream);
fclose($stream);
}, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . str_replace('"', '', $fileName) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
@@ -135,7 +154,28 @@ abstract class Controller extends BaseController
return response()->make($content, 200, [
'Content-Type' => $mime,
'Content-Disposition' => 'inline; filename="' . $fileName . '"',
'Content-Disposition' => 'inline; filename="' . str_replace('"', '', $fileName) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
/**
* Create a file download response that provides the file with a content-type
* correct for the file, in a way so the browser can show the content in browser,
* for a given content stream.
*/
protected function streamedInlineDownloadResponse($stream, string $fileName): StreamedResponse
{
$sniffContent = fread($stream, 1000);
$mime = (new WebSafeMimeSniffer())->sniff($sniffContent);
return response()->stream(function () use ($sniffContent, $stream) {
echo $sniffContent;
fpassthru($stream);
fclose($stream);
}, 200, [
'Content-Type' => $mime,
'Content-Disposition' => 'inline; filename="' . str_replace('"', '', $fileName) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}

View File

@@ -107,14 +107,6 @@ class HomeController extends Controller
return view('home.default', $commonData);
}
/**
* Get custom head HTML, Used in ajax calls to show in editor.
*/
public function customHeadContent()
{
return view('common.custom-head');
}
/**
* Show the view for /robots.txt.
*/

View File

@@ -10,17 +10,19 @@ use BookStack\Entities\Tools\Cloner;
use BookStack\Entities\Tools\NextPreviousContentLocator;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditActivity;
use BookStack\Entities\Tools\PageEditorData;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Throwable;
class PageController extends Controller
{
protected $pageRepo;
protected PageRepo $pageRepo;
/**
* PageController constructor.
@@ -81,22 +83,15 @@ class PageController extends Controller
*
* @throws NotFoundException
*/
public function editDraft(string $bookSlug, int $pageId)
public function editDraft(Request $request, string $bookSlug, int $pageId)
{
$draft = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-create', $draft->getParent());
$editorData = new PageEditorData($draft, $this->pageRepo, $request->query('editor', ''));
$this->setPageTitle(trans('entities.pages_edit_draft'));
$draftsEnabled = $this->isSignedIn();
$templates = $this->pageRepo->getTemplates(10);
return view('pages.edit', [
'page' => $draft,
'book' => $draft->book,
'isDraft' => true,
'draftsEnabled' => $draftsEnabled,
'templates' => $templates,
]);
return view('pages.edit', $editorData->getViewData());
}
/**
@@ -187,43 +182,19 @@ class PageController extends Controller
*
* @throws NotFoundException
*/
public function edit(string $bookSlug, string $pageSlug)
public function edit(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$page->isDraft = false;
$editActivity = new PageEditActivity($page);
// Check for active editing
$warnings = [];
if ($editActivity->hasActiveEditing()) {
$warnings[] = $editActivity->activeEditingMessage();
$editorData = new PageEditorData($page, $this->pageRepo, $request->query('editor', ''));
if ($editorData->getWarnings()) {
$this->showWarningNotification(implode("\n", $editorData->getWarnings()));
}
// Check for a current draft version for this user
$userDraft = $this->pageRepo->getUserDraft($page);
if ($userDraft !== null) {
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
$page->isDraft = true;
$warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
}
if (count($warnings) > 0) {
$this->showWarningNotification(implode("\n", $warnings));
}
$templates = $this->pageRepo->getTemplates(10);
$draftsEnabled = $this->isSignedIn();
$this->setPageTitle(trans('entities.pages_editing_named', ['pageName' => $page->getShortName()]));
return view('pages.edit', [
'page' => $page,
'book' => $page->book,
'current' => $page,
'draftsEnabled' => $draftsEnabled,
'templates' => $templates,
]);
return view('pages.edit', $editorData->getViewData());
}
/**
@@ -364,15 +335,22 @@ class PageController extends Controller
*/
public function showRecentlyUpdated()
{
$pages = Page::visible()->orderBy('updated_at', 'desc')
$visibleBelongsScope = function (BelongsTo $query) {
$query->scopes('visible');
};
$pages = Page::visible()->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope])
->orderBy('updated_at', 'desc')
->paginate(20)
->setPath(url('/pages/recently-updated'));
$this->setPageTitle(trans('entities.recently_updated_pages'));
return view('common.detailed-listing-paginated', [
'title' => trans('entities.recently_updated_pages'),
'entities' => $pages,
'title' => trans('entities.recently_updated_pages'),
'entities' => $pages,
'showUpdatedBy' => true,
'showPath' => true,
]);
}

View File

@@ -124,11 +124,8 @@ class PageRevisionController extends Controller
throw new NotFoundException("Revision #{$revId} not found");
}
// Get the current revision for the page
$currentRevision = $page->getCurrentRevision();
// Check if its the latest revision, cannot delete latest revision.
if (intval($currentRevision->id) === intval($revId)) {
// Check if it's the latest revision, cannot delete the latest revision.
if (intval($page->currentRevision->id ?? null) === intval($revId)) {
$this->showErrorNotification(trans('entities.revision_cannot_delete_latest'));
return redirect($page->getUrl('/revisions'));

View File

@@ -5,6 +5,7 @@ namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Repos\DeletionRepo;
use BookStack\Entities\Tools\TrashCan;
class RecycleBinController extends Controller
@@ -73,12 +74,9 @@ class RecycleBinController extends Controller
*
* @throws \Exception
*/
public function restore(string $id)
public function restore(DeletionRepo $deletionRepo, string $id)
{
/** @var Deletion $deletion */
$deletion = Deletion::query()->findOrFail($id);
$this->logActivity(ActivityType::RECYCLE_BIN_RESTORE, $deletion);
$restoreCount = (new TrashCan())->restoreFromDeletion($deletion);
$restoreCount = $deletionRepo->restore((int) $id);
$this->showSuccessNotification(trans('settings.recycle_bin_restore_notification', ['count' => $restoreCount]));
@@ -103,12 +101,9 @@ class RecycleBinController extends Controller
*
* @throws \Exception
*/
public function destroy(string $id)
public function destroy(DeletionRepo $deletionRepo, string $id)
{
/** @var Deletion $deletion */
$deletion = Deletion::query()->findOrFail($id);
$this->logActivity(ActivityType::RECYCLE_BIN_DESTROY, $deletion);
$deleteCount = (new TrashCan())->destroyFromDeletion($deletion);
$deleteCount = $deletionRepo->destroy((int) $id);
$this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));

View File

@@ -9,28 +9,37 @@ use Illuminate\Http\Request;
class SettingController extends Controller
{
protected $imageRepo;
protected ImageRepo $imageRepo;
protected array $settingCategories = ['features', 'customization', 'registration'];
/**
* SettingController constructor.
*/
public function __construct(ImageRepo $imageRepo)
{
$this->imageRepo = $imageRepo;
}
/**
* Display a listing of the settings.
* Handle requests to the settings index path.
*/
public function index()
{
return redirect('/settings/features');
}
/**
* Display the settings for the given category.
*/
public function category(string $category)
{
$this->ensureCategoryExists($category);
$this->checkPermission('settings-manage');
$this->setPageTitle(trans('settings.settings'));
// Get application version
$version = trim(file_get_contents(base_path('version')));
return view('settings.index', [
return view('settings.' . $category, [
'category' => $category,
'version' => $version,
'guestUser' => User::getDefault(),
]);
@@ -39,8 +48,9 @@ class SettingController extends Controller
/**
* Update the specified settings in storage.
*/
public function update(Request $request)
public function update(Request $request, string $category)
{
$this->ensureCategoryExists($category);
$this->preventAccessInDemoMode();
$this->checkPermission('settings-manage');
$this->validate($request, [
@@ -57,7 +67,7 @@ class SettingController extends Controller
}
// Update logo image if set
if ($request->hasFile('app_logo')) {
if ($category === 'customization' && $request->hasFile('app_logo')) {
$logoFile = $request->file('app_logo');
$this->imageRepo->destroyByType('system');
$image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
@@ -65,16 +75,21 @@ class SettingController extends Controller
}
// Clear logo image if requested
if ($request->get('app_logo_reset', null)) {
if ($category === 'customization' && $request->get('app_logo_reset', null)) {
$this->imageRepo->destroyByType('system');
setting()->remove('app-logo');
}
$section = $request->get('section', '');
$this->logActivity(ActivityType::SETTINGS_UPDATE, $section);
$this->logActivity(ActivityType::SETTINGS_UPDATE, $category);
$this->showSuccessNotification(trans('settings.settings_save_success'));
$redirectLocation = '/settings#' . $section;
return redirect(rtrim($redirectLocation, '#'));
return redirect("/settings/{$category}");
}
protected function ensureCategoryExists(string $category): void
{
if (!in_array($category, $this->settingCategories)) {
abort(404);
}
}
}

View File

@@ -2,9 +2,9 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\Queries\AllUsersPaginatedAndSorted;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ImageUploadException;
@@ -12,25 +12,21 @@ use BookStack\Exceptions\UserUpdateException;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
class UserController extends Controller
{
protected $user;
protected $userRepo;
protected $inviteService;
protected $imageRepo;
/**
* UserController constructor.
*/
public function __construct(User $user, UserRepo $userRepo, UserInviteService $inviteService, ImageRepo $imageRepo)
public function __construct(UserRepo $userRepo, ImageRepo $imageRepo)
{
$this->user = $user;
$this->userRepo = $userRepo;
$this->inviteService = $inviteService;
$this->imageRepo = $imageRepo;
}
@@ -45,12 +41,16 @@ class UserController extends Controller
'search' => $request->get('search', ''),
'sort' => $request->get('sort', 'name'),
];
$users = $this->userRepo->getAllUsersPaginatedAndSorted(20, $listDetails);
$users = (new AllUsersPaginatedAndSorted())->run(20, $listDetails);
$this->setPageTitle(trans('settings.users'));
$users->appends($listDetails);
return view('users.index', ['users' => $users, 'listDetails' => $listDetails]);
return view('users.index', [
'users' => $users,
'listDetails' => $listDetails,
]);
}
/**
@@ -60,59 +60,42 @@ class UserController extends Controller
{
$this->checkPermission('users-manage');
$authMethod = config('auth.method');
$roles = $this->userRepo->getAllRoles();
$roles = Role::query()->orderBy('display_name', 'asc')->get();
$this->setPageTitle(trans('settings.users_add_new'));
return view('users.create', ['authMethod' => $authMethod, 'roles' => $roles]);
}
/**
* Store a newly created user in storage.
* Store a new user in storage.
*
* @throws UserUpdateException
* @throws ValidationException
*/
public function store(Request $request)
{
$this->checkPermission('users-manage');
$validationRules = [
'name' => ['required'],
'email' => ['required', 'email', 'unique:users,email'],
];
$authMethod = config('auth.method');
$sendInvite = ($request->get('send_invite', 'false') === 'true');
$externalAuth = $authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'oidc';
$passwordRequired = ($authMethod === 'standard' && !$sendInvite);
if ($authMethod === 'standard' && !$sendInvite) {
$validationRules['password'] = ['required', Password::default()];
$validationRules['password-confirm'] = ['required', 'same:password'];
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
$validationRules['external_auth_id'] = ['required'];
}
$this->validate($request, $validationRules);
$validationRules = [
'name' => ['required'],
'email' => ['required', 'email', 'unique:users,email'],
'language' => ['string'],
'roles' => ['array'],
'roles.*' => ['integer'],
'password' => $passwordRequired ? ['required', Password::default()] : null,
'password-confirm' => $passwordRequired ? ['required', 'same:password'] : null,
'external_auth_id' => $externalAuth ? ['required'] : null,
];
$user = $this->user->fill($request->all());
$validated = $this->validate($request, array_filter($validationRules));
if ($authMethod === 'standard') {
$user->password = bcrypt($request->get('password', Str::random(32)));
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
$user->external_auth_id = $request->get('external_auth_id');
}
$user->refreshSlug();
$user->save();
if ($sendInvite) {
$this->inviteService->sendInvitation($user);
}
if ($request->filled('roles')) {
$roles = $request->get('roles');
$this->userRepo->setUserRoles($user, $roles);
}
$this->userRepo->downloadAndAssignUserAvatar($user);
$this->logActivity(ActivityType::USER_CREATE, $user);
DB::transaction(function () use ($validated, $sendInvite) {
$this->userRepo->create($validated, $sendInvite);
});
return redirect('/settings/users');
}
@@ -125,14 +108,14 @@ class UserController extends Controller
$this->checkPermissionOrCurrentUser('users-manage', $id);
/** @var User $user */
$user = $this->user->newQuery()->with(['apiTokens', 'mfaValues'])->findOrFail($id);
$user = User::query()->with(['apiTokens', 'mfaValues'])->findOrFail($id);
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
$mfaMethods = $user->mfaValues->groupBy('method');
$this->setPageTitle(trans('settings.user_profile'));
$roles = $this->userRepo->getAllRoles();
$roles = Role::query()->orderBy('display_name', 'asc')->get();
return view('users.edit', [
'user' => $user,
@@ -155,51 +138,20 @@ class UserController extends Controller
$this->preventAccessInDemoMode();
$this->checkPermissionOrCurrentUser('users-manage', $id);
$this->validate($request, [
$validated = $this->validate($request, [
'name' => ['min:2'],
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
'password' => ['required_with:password_confirm', Password::default()],
'password-confirm' => ['same:password', 'required_with:password'],
'setting' => ['array'],
'language' => ['string'],
'roles' => ['array'],
'roles.*' => ['integer'],
'external_auth_id' => ['string'],
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
]);
$user = $this->userRepo->getById($id);
$user->fill($request->except(['email']));
// Email updates
if (userCan('users-manage') && $request->filled('email')) {
$user->email = $request->get('email');
}
// Refresh the slug if the user's name has changed
if ($user->isDirty('name')) {
$user->refreshSlug();
}
// Role updates
if (userCan('users-manage') && $request->filled('roles')) {
$roles = $request->get('roles');
$this->userRepo->setUserRoles($user, $roles);
}
// Password updates
if ($request->filled('password')) {
$password = $request->get('password');
$user->password = bcrypt($password);
}
// External auth id updates
if (user()->can('users-manage') && $request->filled('external_auth_id')) {
$user->external_auth_id = $request->get('external_auth_id');
}
// Save an user-specific settings
if ($request->filled('setting')) {
foreach ($request->get('setting') as $key => $value) {
setting()->putUser($user, $key, $value);
}
}
$this->userRepo->update($user, $validated, userCan('users-manage'));
// Save profile image if in request
if ($request->hasFile('profile_image')) {
@@ -207,6 +159,7 @@ class UserController extends Controller
$this->imageRepo->destroyImage($user->avatar);
$image = $this->imageRepo->saveNew($imageUpload, 'user', $user->id);
$user->image_id = $image->id;
$user->save();
}
// Delete the profile image if reset option is in request
@@ -214,11 +167,7 @@ class UserController extends Controller
$this->imageRepo->destroyImage($user->avatar);
}
$user->save();
$this->showSuccessNotification(trans('settings.users_edit_success'));
$this->logActivity(ActivityType::USER_UPDATE, $user);
$redirectUrl = userCan('users-manage') ? '/settings/users' : ('/settings/users/' . $user->id);
$redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}";
return redirect($redirectUrl);
}
@@ -249,21 +198,7 @@ class UserController extends Controller
$user = $this->userRepo->getById($id);
$newOwnerId = $request->get('new_owner_id', null);
if ($this->userRepo->isOnlyAdmin($user)) {
$this->showErrorNotification(trans('errors.users_cannot_delete_only_admin'));
return redirect($user->getEditUrl());
}
if ($user->system_name === 'public') {
$this->showErrorNotification(trans('errors.users_cannot_delete_guest'));
return redirect($user->getEditUrl());
}
$this->userRepo->destroy($user, $newOwnerId);
$this->showSuccessNotification(trans('settings.users_delete_success'));
$this->logActivity(ActivityType::USER_DELETE, $user);
return redirect('/settings/users');
}
@@ -348,7 +283,7 @@ class UserController extends Controller
$newState = $request->get('expand', 'false');
$user = $this->user->findOrFail($id);
$user = $this->userRepo->getById($id);
setting()->putUser($user, 'section_expansion#' . $key, $newState);
return response('', 204);
@@ -371,7 +306,7 @@ class UserController extends Controller
$order = 'asc';
}
$user = $this->user->findOrFail($userId);
$user = $this->userRepo->getById($userId);
$sortKey = $listName . '_sort';
$orderKey = $listName . '_sort_order';
setting()->putUser($user, $sortKey, $sort);

View File

@@ -3,6 +3,8 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityQueries;
use BookStack\Auth\Queries\UserContentCounts;
use BookStack\Auth\Queries\UserRecentlyCreatedContent;
use BookStack\Auth\UserRepo;
class UserProfileController extends Controller
@@ -15,8 +17,8 @@ class UserProfileController extends Controller
$user = $repo->getBySlug($slug);
$userActivity = $activities->userActivity($user);
$recentlyCreated = $repo->getRecentlyCreated($user, 5);
$assetCounts = $repo->getAssetCounts($user);
$recentlyCreated = (new UserRecentlyCreatedContent())->run($user, 5);
$assetCounts = (new UserContentCounts())->run($user);
$this->setPageTitle($user->name);

View File

@@ -8,10 +8,7 @@ use Illuminate\Http\Request;
class ApplyCspRules
{
/**
* @var CspService
*/
protected $cspService;
protected CspService $cspService;
public function __construct(CspService $cspService)
{
@@ -35,10 +32,8 @@ class ApplyCspRules
$response = $next($request);
$this->cspService->setFrameAncestors($response);
$this->cspService->setScriptSrc($response);
$this->cspService->setObjectSrc($response);
$this->cspService->setBaseUri($response);
$cspHeader = $this->cspService->getCspHeader();
$response->headers->set('Content-Security-Policy', $cspHeader, false);
return $response;
}

View File

@@ -11,7 +11,7 @@ class Localization
/**
* Array of right-to-left locales.
*/
protected $rtlLocales = ['ar', 'he'];
protected $rtlLocales = ['ar', 'fa', 'he'];
/**
* Map of BookStack locale names to best-estimate system locale names.
@@ -29,6 +29,8 @@ class Localization
'es' => 'es_ES',
'es_AR' => 'es_AR',
'et' => 'et_EE',
'eu' => 'eu_ES',
'fa' => 'fa_IR',
'fr' => 'fr_FR',
'he' => 'he_IL',
'hr' => 'hr_HR',

View File

@@ -8,20 +8,38 @@ class Request extends LaravelRequest
{
/**
* Override the default request methods to get the scheme and host
* to set the custom APP_URL, if set.
* to directly use the custom APP_URL, if set.
*
* @return \Illuminate\Config\Repository|mixed|string
* @return string
*/
public function getSchemeAndHttpHost()
{
$base = config('app.url', null);
$appUrl = config('app.url', null);
if ($base) {
$base = trim($base, '/');
} else {
$base = $this->getScheme() . '://' . $this->getHttpHost();
if ($appUrl) {
return implode('/', array_slice(explode('/', $appUrl), 0, 3));
}
return $base;
return parent::getSchemeAndHttpHost();
}
/**
* Override the default request methods to get the base URL
* to directly use the custom APP_URL, if set.
* The base URL never ends with a / but should start with one if not empty.
*
* @return string
*/
public function getBaseUrl()
{
$appUrl = config('app.url', null);
if ($appUrl) {
$parsedBaseUrl = rtrim(implode('/', array_slice(explode('/', $appUrl), 3)), '/');
return empty($parsedBaseUrl) ? '' : ('/' . $parsedBaseUrl);
}
return parent::getBaseUrl();
}
}

View File

@@ -2,35 +2,33 @@
namespace BookStack\Notifications;
use BookStack\Auth\User;
use Illuminate\Notifications\Messages\MailMessage;
class UserInvite extends MailNotification
{
public $token;
/**
* Create a new notification instance.
*
* @param string $token
*/
public function __construct($token)
public function __construct(string $token)
{
$this->token = $token;
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
*
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
public function toMail(User $notifiable): MailMessage
{
$appName = ['appName' => setting('app-name')];
$language = setting()->getUser($notifiable, 'language');
return $this->newMailMessage()
->subject(trans('auth.user_invite_email_subject', $appName))
->greeting(trans('auth.user_invite_email_greeting', $appName))
->line(trans('auth.user_invite_email_text'))
->action(trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
->subject(trans('auth.user_invite_email_subject', $appName, $language))
->greeting(trans('auth.user_invite_email_greeting', $appName, $language))
->line(trans('auth.user_invite_email_text', [], $language))
->action(trans('auth.user_invite_email_action', [], $language), url('/register/invite/' . $this->token));
}
}

View File

@@ -51,12 +51,12 @@ class AppServiceProvider extends ServiceProvider
// Allow longer string lengths after upgrade to utf8mb4
Schema::defaultStringLength(191);
// Set morph-map due to namespace changes
Relation::morphMap([
'BookStack\\Bookshelf' => Bookshelf::class,
'BookStack\\Book' => Book::class,
'BookStack\\Chapter' => Chapter::class,
'BookStack\\Page' => Page::class,
// Set morph-map for our relations to friendlier aliases
Relation::enforceMorphMap([
'bookshelf' => Bookshelf::class,
'book' => Book::class,
'chapter' => Chapter::class,
'page' => Page::class,
]);
// View Composers

View File

@@ -23,6 +23,7 @@ class AuthServiceProvider extends ServiceProvider
public function boot()
{
// Password Configuration
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
Password::defaults(function () {
return Password::min(8);
});

View File

@@ -93,6 +93,8 @@ class ThemeEvents
* @param string $event
* @param \BookStack\Actions\Webhook $webhook
* @param string|\BookStack\Interfaces\Loggable $detail
* @param \BookStack\Auth\User $initiator
* @param int $initiatedTime
*/
const WEBHOOK_CALL_BEFORE = 'webhook_call_before';
}

View File

@@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
class AttachmentService
{
protected $fileSystem;
protected FilesystemManager $fileSystem;
/**
* AttachmentService constructor.
@@ -73,6 +73,18 @@ class AttachmentService
return $this->getStorageDisk()->get($this->adjustPathForStorageDisk($attachment->path));
}
/**
* Stream an attachment from storage.
*
* @throws FileNotFoundException
*
* @return resource|null
*/
public function streamAttachmentFromStorage(Attachment $attachment)
{
return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path));
}
/**
* Store a new attachment upon user upload.
*
@@ -211,8 +223,6 @@ class AttachmentService
*/
protected function putFileInStorage(UploadedFile $uploadedFile): string
{
$attachmentData = file_get_contents($uploadedFile->getRealPath());
$storage = $this->getStorageDisk();
$basePath = 'uploads/files/' . date('Y-m-M') . '/';
@@ -221,10 +231,11 @@ class AttachmentService
$uploadFileName = Str::random(3) . $uploadFileName;
}
$attachmentStream = fopen($uploadedFile->getRealPath(), 'r');
$attachmentPath = $basePath . $uploadFileName;
try {
$storage->put($this->adjustPathForStorageDisk($attachmentPath), $attachmentData);
$storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream);
} catch (Exception $e) {
Log::error('Error when attempting file upload:' . $e->getMessage());

View File

@@ -5,6 +5,7 @@ namespace BookStack\Uploads;
use BookStack\Exceptions\ImageUploadException;
use ErrorException;
use Exception;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Intervention\Image\Exception\NotSupportedException;
use Intervention\Image\Image as InterventionImage;
use Intervention\Image\ImageManager;
use League\Flysystem\Util;
use Psr\SimpleCache\InvalidArgumentException;
@@ -308,6 +310,8 @@ class ImageService
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
}
$this->orientImageToOriginalExif($thumb, $imageData);
if ($keepRatio) {
$thumb->resize($width, $height, function ($constraint) {
$constraint->aspectRatio();
@@ -328,6 +332,49 @@ class ImageService
return $thumbData;
}
/**
* Orientate the given intervention image based upon the given original image data.
* Intervention does have an `orientate` method but the exif data it needs is lost before it
* can be used (At least when created using binary string data) so we need to do some
* implementation on our side to use the original image data.
* Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
* Copyright (c) Oliver Vogel, MIT License.
*/
protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
{
if (!extension_loaded('exif')) {
return;
}
$stream = Utils::streamFor($originalData)->detach();
$exif = @exif_read_data($stream);
$orientation = $exif ? ($exif['Orientation'] ?? null) : null;
switch ($orientation) {
case 2:
$image->flip();
break;
case 3:
$image->rotate(180);
break;
case 4:
$image->rotate(180)->flip();
break;
case 5:
$image->rotate(270)->flip();
break;
case 6:
$image->rotate(270);
break;
case 7:
$image->rotate(90)->flip();
break;
case 8:
$image->rotate(90);
break;
}
}
/**
* Get the raw data content from an image.
*

View File

@@ -3,12 +3,10 @@
namespace BookStack\Util;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class CspService
{
/** @var string */
protected $nonce;
protected string $nonce;
public function __construct(string $nonce = '')
{
@@ -24,37 +22,34 @@ class CspService
}
/**
* Sets CSP 'script-src' headers to restrict the forms of script that can
* run on the page.
* Get the CSP headers for the application.
*/
public function setScriptSrc(Response $response)
public function getCspHeader(): string
{
if (config('app.allow_content_scripts')) {
return;
}
$parts = [
'http:',
'https:',
'\'nonce-' . $this->nonce . '\'',
'\'strict-dynamic\'',
$headers = [
$this->getFrameAncestors(),
$this->getFrameSrc(),
$this->getScriptSrc(),
$this->getObjectSrc(),
$this->getBaseUri(),
];
$value = 'script-src ' . implode(' ', $parts);
$response->headers->set('Content-Security-Policy', $value, false);
return implode('; ', array_filter($headers));
}
/**
* Sets CSP "frame-ancestors" headers to restrict the hosts that BookStack can be
* iframed within. Also adjusts the cookie samesite options so that cookies will
* operate in the third-party context.
* Get the CSP rules for the application for a HTML meta tag.
*/
public function setFrameAncestors(Response $response)
public function getCspMetaTagValue(): string
{
$iframeHosts = $this->getAllowedIframeHosts();
array_unshift($iframeHosts, "'self'");
$cspValue = 'frame-ancestors ' . implode(' ', $iframeHosts);
$response->headers->set('Content-Security-Policy', $cspValue, false);
$headers = [
$this->getFrameSrc(),
$this->getScriptSrc(),
$this->getObjectSrc(),
$this->getBaseUri(),
];
return implode('; ', array_filter($headers));
}
/**
@@ -66,25 +61,67 @@ class CspService
}
/**
* Sets CSP 'object-src' headers to restrict the types of dynamic content
* that can be embedded on the page.
* Create CSP 'script-src' rule to restrict the forms of script that can run on the page.
*/
public function setObjectSrc(Response $response)
protected function getScriptSrc(): string
{
if (config('app.allow_content_scripts')) {
return;
return '';
}
$response->headers->set('Content-Security-Policy', 'object-src \'self\'', false);
$parts = [
'http:',
'https:',
'\'nonce-' . $this->nonce . '\'',
'\'strict-dynamic\'',
];
return 'script-src ' . implode(' ', $parts);
}
/**
* Sets CSP 'base-uri' headers to restrict what base tags can be set on
* Create CSP "frame-ancestors" rule to restrict the hosts that BookStack can be iframed within.
*/
protected function getFrameAncestors(): string
{
$iframeHosts = $this->getAllowedIframeHosts();
array_unshift($iframeHosts, "'self'");
return 'frame-ancestors ' . implode(' ', $iframeHosts);
}
/**
* Creates CSP "frame-src" rule to restrict what hosts/sources can be loaded
* within iframes to provide an allow-list-style approach to iframe content.
*/
protected function getFrameSrc(): string
{
$iframeHosts = $this->getAllowedIframeSources();
array_unshift($iframeHosts, "'self'");
return 'frame-src ' . implode(' ', $iframeHosts);
}
/**
* Creates CSP 'object-src' rule to restrict the types of dynamic content
* that can be embedded on the page.
*/
protected function getObjectSrc(): string
{
if (config('app.allow_content_scripts')) {
return '';
}
return "object-src 'self'";
}
/**
* Creates CSP 'base-uri' rule to restrict what base tags can be set on
* the page to prevent manipulation of relative links.
*/
public function setBaseUri(Response $response)
protected function getBaseUri(): string
{
$response->headers->set('Content-Security-Policy', 'base-uri \'self\'', false);
return "base-uri 'self'";
}
protected function getAllowedIframeHosts(): array
@@ -93,4 +130,21 @@ class CspService
return array_filter(explode(' ', $hosts));
}
protected function getAllowedIframeSources(): array
{
$sources = config('app.iframe_sources', '');
$hosts = array_filter(explode(' ', $sources));
// Extract drawing service url to allow embedding if active
$drawioConfigValue = config('services.drawio');
if ($drawioConfigValue) {
$drawioSource = is_string($drawioConfigValue) ? $drawioConfigValue : 'https://embed.diagrams.net/';
$drawioSourceParsed = parse_url($drawioSource);
$drawioHost = $drawioSourceParsed['scheme'] . '://' . $drawioSourceParsed['host'];
$hosts[] = $drawioHost;
}
return $hosts;
}
}

View File

@@ -8,7 +8,7 @@
"license": "MIT",
"type": "project",
"require": {
"php": "^7.3|^8.0",
"php": "^7.4|^8.0",
"ext-curl": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
@@ -17,8 +17,8 @@
"ext-mbstring": "*",
"ext-xml": "*",
"bacon/bacon-qr-code": "^2.0",
"barryvdh/laravel-dompdf": "^0.9.0",
"barryvdh/laravel-snappy": "^0.4.8",
"barryvdh/laravel-dompdf": "^1.0",
"barryvdh/laravel-snappy": "^1.0",
"doctrine/dbal": "^3.1",
"filp/whoops": "^2.14",
"guzzlehttp/guzzle": "^7.4",
@@ -41,7 +41,7 @@
"socialiteproviders/okta": "^4.1",
"socialiteproviders/slack": "^4.1",
"socialiteproviders/twitch": "^5.3",
"ssddanbrown/htmldiff": "^1.0.1"
"ssddanbrown/htmldiff": "^1.0.2"
},
"require-dev": {
"fakerphp/faker": "^1.16",
@@ -95,7 +95,7 @@
"preferred-install": "dist",
"sort-packages": true,
"platform": {
"php": "7.3.0"
"php": "7.4.0"
}
},
"extra": {

1651
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
<?php
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class AddEditorChangeFieldAndPermission extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// Add the new 'editor' column to the pages table
Schema::table('pages', function (Blueprint $table) {
$table->string('editor', 50)->default('');
});
// Populate the new 'editor' column
// We set it to 'markdown' for pages currently with markdown content
DB::table('pages')->where('markdown', '!=', '')->update(['editor' => 'markdown']);
// We set it to 'wysiwyg' where we have HTML but no markdown
DB::table('pages')->where('markdown', '=', '')
->where('html', '!=', '')
->update(['editor' => 'wysiwyg']);
// Give the admin user permission to change the editor
$adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
$permissionId = DB::table('role_permissions')->insertGetId([
'name' => 'editor-change',
'display_name' => 'Change page editor',
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
'permission_id' => $permissionId,
]);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
// Drop the new column from the pages table
Schema::table('pages', function (Blueprint $table) {
$table->dropColumn('editor');
});
// Remove traces of the role permission
DB::table('role_permissions')->where('name', '=', 'editor-change')->delete();
}
}

View File

@@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
class UpdatePolymorphicTypes extends Migration
{
/**
* Mapping of old polymorphic types to new simpler values.
*/
protected $changeMap = [
'BookStack\\Bookshelf' => 'bookshelf',
'BookStack\\Book' => 'book',
'BookStack\\Chapter' => 'chapter',
'BookStack\\Page' => 'page',
];
/**
* Mapping of tables and columns that contain polymorphic types.
*/
protected $columnsByTable = [
'activities' => 'entity_type',
'comments' => 'entity_type',
'deletions' => 'deletable_type',
'entity_permissions' => 'restrictable_type',
'favourites' => 'favouritable_type',
'joint_permissions' => 'entity_type',
'search_terms' => 'entity_type',
'tags' => 'entity_type',
'views' => 'viewable_type',
];
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
foreach ($this->columnsByTable as $table => $column) {
foreach ($this->changeMap as $oldVal => $newVal) {
DB::table($table)
->where([$column => $oldVal])
->update([$column => $newVal]);
}
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
foreach ($this->columnsByTable as $table => $column) {
foreach ($this->changeMap as $oldVal => $newVal) {
DB::table($table)
->where([$column => $newVal])
->update([$column => $oldVal]);
}
}
}
}

View File

@@ -0,0 +1,7 @@
{
"name": "Dan Brown",
"email": "dannyb@example.com",
"roles": [1],
"language": "fr",
"send_invite": true
}

View File

@@ -0,0 +1,3 @@
{
"migrate_ownership_id": 5
}

View File

@@ -0,0 +1,7 @@
{
"name": "Dan Spaggleforth",
"email": "dspaggles@example.com",
"roles": [2],
"language": "de",
"password": "hunter2000"
}

View File

@@ -0,0 +1,3 @@
{
"delete_count": 2
}

View File

@@ -0,0 +1,64 @@
{
"data": [
{
"id": 18,
"deleted_by": 1,
"created_at": "2022-04-20T12:57:46.000000Z",
"updated_at": "2022-04-20T12:57:46.000000Z",
"deletable_type": "page",
"deletable_id": 2582,
"deletable": {
"id": 2582,
"book_id": 25,
"chapter_id": 0,
"name": "A Wonderful Page",
"slug": "a-wonderful-page",
"priority": 9,
"created_at": "2022-02-08T00:44:45.000000Z",
"updated_at": "2022-04-20T12:57:46.000000Z",
"created_by": 1,
"updated_by": 1,
"draft": false,
"revision_count": 1,
"template": false,
"owned_by": 1,
"editor": "wysiwyg",
"book_slug": "a-great-book",
"parent": {
"id": 25,
"name": "A Great Book",
"slug": "a-great-book",
"description": "",
"created_at": "2022-01-24T16:14:28.000000Z",
"updated_at": "2022-03-06T15:14:50.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1,
"type": "book"
}
}
},
{
"id": 19,
"deleted_by": 1,
"created_at": "2022-04-25T16:07:46.000000Z",
"updated_at": "2022-04-25T16:07:46.000000Z",
"deletable_type": "book",
"deletable_id": 13,
"deletable": {
"id": 13,
"name": "A Big Book!",
"slug": "a-big-book",
"description": "This is a very large book with loads of cool stuff in it!",
"created_at": "2021-11-08T11:26:43.000000Z",
"updated_at": "2022-04-25T16:07:47.000000Z",
"created_by": 27,
"updated_by": 1,
"owned_by": 1,
"pages_count": 208,
"chapters_count": 50
}
}
],
"total": 2
}

View File

@@ -0,0 +1,3 @@
{
"restore_count": 2
}

View File

@@ -0,0 +1,19 @@
{
"id": 1,
"name": "Dan Brown",
"email": "dannyb@example.com",
"created_at": "2022-02-03T16:27:55.000000Z",
"updated_at": "2022-02-03T16:27:55.000000Z",
"external_auth_id": "abc123456",
"slug": "dan-brown",
"last_activity_at": "2022-02-03T16:27:55.000000Z",
"profile_url": "https://docs.example.com/user/dan-brown",
"edit_url": "https://docs.example.com/settings/users/1",
"avatar_url": "https://docs.example.com/uploads/images/user/2021-10/thumbs-50-50/profile-2021.jpg",
"roles": [
{
"id": 1,
"display_name": "Admin"
}
]
}

View File

@@ -0,0 +1,33 @@
{
"data": [
{
"id": 1,
"name": "Dan Brown",
"email": "dannyb@example.com",
"created_at": "2022-02-03T16:27:55.000000Z",
"updated_at": "2022-02-03T16:27:55.000000Z",
"external_auth_id": "abc123456",
"slug": "dan-brown",
"user_id": 1,
"last_activity_at": "2022-02-03T16:27:55.000000Z",
"profile_url": "https://docs.example.com/user/dan-brown",
"edit_url": "https://docs.example.com/settings/users/1",
"avatar_url": "https://docs.example.com/uploads/images/user/2021-10/thumbs-50-50/profile-2021.jpg"
},
{
"id": 2,
"name": "Benny",
"email": "benny@example.com",
"created_at": "2022-01-31T20:39:24.000000Z",
"updated_at": "2021-11-18T17:10:58.000000Z",
"external_auth_id": "",
"slug": "benny",
"user_id": 2,
"last_activity_at": "2022-01-31T20:39:24.000000Z",
"profile_url": "https://docs.example.com/user/benny",
"edit_url": "https://docs.example.com/settings/users/2",
"avatar_url": "https://docs.example.com/uploads/images/user/2021-11/thumbs-50-50/guest.jpg"
}
],
"total": 28
}

View File

@@ -0,0 +1,19 @@
{
"id": 1,
"name": "Dan Brown",
"email": "dannyb@example.com",
"created_at": "2022-02-03T16:27:55.000000Z",
"updated_at": "2022-02-03T16:27:55.000000Z",
"external_auth_id": "abc123456",
"slug": "dan-brown",
"last_activity_at": "2022-02-03T16:27:55.000000Z",
"profile_url": "https://docs.example.com/user/dan-brown",
"edit_url": "https://docs.example.com/settings/users/1",
"avatar_url": "https://docs.example.com/uploads/images/user/2021-10/thumbs-50-50/profile-2021.jpg",
"roles": [
{
"id": 1,
"display_name": "Admin"
}
]
}

View File

@@ -0,0 +1,19 @@
{
"id": 1,
"name": "Dan Spaggleforth",
"email": "dspaggles@example.com",
"created_at": "2022-02-03T16:27:55.000000Z",
"updated_at": "2022-02-03T16:27:55.000000Z",
"external_auth_id": "abc123456",
"slug": "dan-spaggleforth",
"last_activity_at": "2022-02-03T16:27:55.000000Z",
"profile_url": "https://docs.example.com/user/dan-spaggleforth",
"edit_url": "https://docs.example.com/settings/users/1",
"avatar_url": "https://docs.example.com/uploads/images/user/2021-10/thumbs-50-50/profile-2021.jpg",
"roles": [
{
"id": 2,
"display_name": "Editors"
}
]
}

32
dev/build/esbuild.js Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
const esbuild = require('esbuild');
const fs = require('fs');
const path = require('path');
// Check if we're building for production
// (Set via passing `production` as first argument)
const isProd = process.argv[2] === 'production';
// Gather our input files
const jsInDir = path.join(__dirname, '../../resources/js');
const jsInDirFiles = fs.readdirSync(jsInDir, 'utf8');
const entryFiles = jsInDirFiles
.filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
.map(f => path.join(jsInDir, f));
// Locate our output directory
const outDir = path.join(__dirname, '../../public/dist');
// Build via esbuild
esbuild.build({
bundle: true,
entryPoints: entryFiles,
outdir: outDir,
sourcemap: true,
target: 'es2020',
mainFields: ['module', 'main'],
format: 'esm',
minify: isProd,
logLevel: "info",
}).catch(() => process.exit(1));

View File

@@ -1,4 +1,4 @@
FROM php:7.4-apache
FROM php:8.1-apache
ENV APACHE_DOCUMENT_ROOT /app/public
WORKDIR /app
@@ -8,6 +8,8 @@ RUN apt-get update -y \
&& apt-get install -y git zip unzip libpng-dev libldap2-dev libzip-dev wait-for-it \
&& docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu \
&& docker-php-ext-install pdo_mysql gd ldap zip \
&& pecl install xdebug \
&& docker-php-ext-enable xdebug \
&& a2enmod rewrite \
&& sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
&& sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf

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