Compare commits

...

381 Commits

Author SHA1 Message Date
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
julesdevops
049d6ba5b2 fix(wysiwyg): preserves line feeds in code block mode 2022-02-05 10:28:44 +01: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
Dan Brown
53f3cca85d Updated version and assets for release v21.12.2 2022-01-10 18:23:44 +00:00
Dan Brown
ed08bbcecc Merge branch 'master' into release 2022-01-10 18:23:19 +00:00
Dan Brown
2aace16704 Updated translator attribution before release v21.12.2 2022-01-10 18:22:43 +00:00
Dan Brown
ade66dcf2f Applied latest styleci changes 2022-01-10 18:18:15 +00:00
Dan Brown
d3eaaf6457 New Crowdin updates (#3148)
* New translations common.php (Czech)

* New translations common.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations errors.php (Japanese)

* New translations entities.php (Japanese)

* New translations common.php (Japanese)

* New translations settings.php (Japanese)

* New translations entities.php (Japanese)

* New translations settings.php (Japanese)

* New translations auth.php (Japanese)

* New translations common.php (Portuguese, Brazilian)

* New translations activities.php (Portuguese, Brazilian)

* New translations activities.php (Portuguese, Brazilian)
2022-01-10 18:17:28 +00:00
Dan Brown
941217d9fb Improved loading for images with failed thumbnails
- A placeholder is now shown in the gallery.
- The page editors will use the original image url if the display
  thumbnail is missing.

For #3142
2022-01-10 18:13:48 +00:00
Dan Brown
4239d4c54d Fixed error on webhooks for recycle bin operations
Updated the getUrl method on deletions to not require any passed
params to align with usage in webhooks.
Probably better to have a proper interface but would require a wider
change.

Fixes #3154
2022-01-10 17:47:49 +00:00
Dan Brown
8d91f4369b Improved custom homepage check on item deletion
Custom homepage usage will now be checked before any actioning
of deletion rather than potentially causing an exception acting
during the deletion.

Previously a deletion could still be created, within the recycle bin,
for the parent which may lead to the page being deleted anyway.

For #3150
2022-01-10 17:04:01 +00:00
Dan Brown
722aa04577 Merge pull request #3153 from AitorMatxi/patch-1
Update auth.php
2022-01-10 16:10:39 +00:00
Aitor Matxinea
2d0abc4164 Update auth.php
Fix misspelled word "As" to "Has".
2022-01-10 11:45:48 +01:00
Dan Brown
c3f7b39a0f Addressed phpstan cases 2022-01-07 13:04:49 +00:00
Dan Brown
de97ebf9b7 Updated version and assets for release v21.12.1 2022-01-06 12:20:37 +00:00
Dan Brown
f492a660a8 Merge branch 'master' into release 2022-01-06 12:20:26 +00:00
Dan Brown
ef11100863 Updated translator attribution before release v21.12.1 2022-01-06 12:20:13 +00:00
Dan Brown
1a26b47782 Applied latest styleCI changes 2022-01-06 12:18:11 +00:00
Dan Brown
cb0d674a71 Merge branch 'sort_changes'
Related to #3134
2022-01-06 12:03:15 +00:00
Dan Brown
4d094331cf New Crowdin updates (#3117)
* New translations auth.php (Bulgarian)

* New translations auth.php (Catalan)

* New translations auth.php (Czech)

* New translations auth.php (Danish)

* New translations auth.php (Hebrew)

* New translations auth.php (Swedish)

* New translations auth.php (Hungarian)

* New translations auth.php (Italian)

* New translations auth.php (Japanese)

* New translations auth.php (Korean)

* New translations auth.php (Lithuanian)

* New translations auth.php (Dutch)

* New translations auth.php (Polish)

* New translations auth.php (Russian)

* New translations auth.php (Slovak)

* New translations auth.php (Slovenian)

* New translations settings.php (Korean)

* New translations settings.php (Lithuanian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Norwegian Bokmal)

* New translations settings.php (Bosnian)

* New translations settings.php (Latvian)

* New translations settings.php (Estonian)

* New translations settings.php (Croatian)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Persian)

* New translations settings.php (Indonesian)

* New translations settings.php (Vietnamese)

* New translations settings.php (Dutch)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Turkish)

* New translations settings.php (Swedish)

* New translations settings.php (Slovenian)

* New translations settings.php (Slovak)

* New translations settings.php (Russian)

* New translations settings.php (Portuguese)

* New translations settings.php (Polish)

* New translations settings.php (German Informal)

* New translations settings.php (Spanish)

* New translations activities.php (Spanish)

* New translations auth.php (Spanish)

* New translations common.php (Spanish)

* New translations settings.php (Spanish)

* New translations auth.php (German)

* New translations passwords.php (German)

* New translations settings.php (German)

* New translations activities.php (German)

* New translations auth.php (German)

* New translations auth.php (German Informal)

* New translations common.php (German)

* New translations entities.php (German)

* New translations errors.php (German)

* New translations errors.php (German Informal)

* New translations settings.php (German)

* New translations settings.php (German Informal)

* New translations entities.php (Japanese)

* New translations entities.php (Vietnamese)

* New translations entities.php (Slovak)

* New translations entities.php (Slovenian)

* New translations entities.php (Swedish)

* New translations entities.php (Turkish)

* New translations entities.php (Chinese Simplified)

* New translations entities.php (Chinese Traditional)

* New translations entities.php (Portuguese, Brazilian)

* New translations entities.php (Polish)

* New translations entities.php (Indonesian)

* New translations entities.php (Persian)

* New translations entities.php (Croatian)

* New translations entities.php (Estonian)

* New translations entities.php (Latvian)

* New translations entities.php (Bosnian)

* New translations entities.php (Russian)

* New translations entities.php (Dutch)

* New translations entities.php (Portuguese)

* New translations entities.php (Bulgarian)

* New translations entities.php (Ukrainian)

* New translations entities.php (Spanish, Argentina)

* New translations entities.php (Norwegian Bokmal)

* New translations entities.php (French)

* New translations entities.php (Spanish)

* New translations entities.php (Arabic)

* New translations entities.php (Catalan)

* New translations entities.php (Lithuanian)

* New translations entities.php (Czech)

* New translations entities.php (Danish)

* New translations entities.php (German)

* New translations entities.php (Hebrew)

* New translations entities.php (Hungarian)

* New translations entities.php (Italian)

* New translations entities.php (Korean)

* New translations entities.php (German Informal)

* New translations entities.php (Spanish)

* New translations auth.php (Portuguese)

* New translations common.php (Portuguese)

* New translations errors.php (Portuguese)

* New translations settings.php (Portuguese)

* New translations activities.php (French)

* New translations activities.php (French)

* New translations auth.php (French)

* New translations common.php (French)

* New translations entities.php (French)

* New translations settings.php (French)

* New translations activities.php (Spanish, Argentina)

* New translations entities.php (Spanish, Argentina)

* New translations auth.php (Spanish, Argentina)

* New translations common.php (Spanish, Argentina)

* New translations activities.php (German Informal)

* New translations common.php (German Informal)

* New translations settings.php (Spanish, Argentina)

* New translations activities.php (Chinese Simplified)

* New translations activities.php (Chinese Simplified)

* New translations auth.php (Chinese Simplified)

* New translations common.php (Chinese Simplified)

* New translations entities.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations activities.php (Estonian)

* New translations auth.php (Estonian)

* New translations common.php (Estonian)

* New translations entities.php (Chinese Simplified)

* New translations entities.php (Estonian)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Estonian)

* New translations settings.php (Estonian)

* New translations validation.php (Estonian)

* New translations auth.php (Italian)

* New translations common.php (Italian)

* New translations entities.php (Italian)

* New translations settings.php (Italian)

* New translations activities.php (Russian)

* New translations auth.php (Russian)

* New translations common.php (Russian)

* New translations activities.php (Russian)

* New translations entities.php (Russian)

* New translations settings.php (Russian)

* New translations activities.php (Japanese)

* New translations auth.php (Portuguese, Brazilian)

* New translations auth.php (Portuguese, Brazilian)

* New translations auth.php (Arabic)

* New translations activities.php (Czech)

* New translations auth.php (Czech)

* New translations activities.php (Czech)

* New translations auth.php (Czech)

* New translations common.php (Czech)

* New translations entities.php (Czech)

* New translations settings.php (Czech)

* New translations activities.php (Czech)

* New translations auth.php (Czech)

* New translations common.php (Czech)

* New translations entities.php (Czech)

* New translations settings.php (Czech)

* New translations auth.php (Czech)

* New translations entities.php (Czech)

* New translations settings.php (Czech)

* New translations auth.php (Czech)

* New translations auth.php (Czech)

* New translations activities.php (Latvian)

* New translations auth.php (Latvian)

* New translations common.php (Latvian)

* New translations entities.php (Latvian)

* New translations settings.php (Latvian)

* New translations activities.php (Latvian)

* New translations settings.php (Latvian)

* New translations activities.php (Italian)

* New translations entities.php (Italian)

* New translations activities.php (Italian)

* New translations settings.php (Italian)

* New translations common.php (Japanese)

* New translations settings.php (French)

* New translations common.php (Vietnamese)

* New translations common.php (Portuguese, Brazilian)

* New translations common.php (Indonesian)

* New translations common.php (Persian)

* New translations common.php (Croatian)

* New translations common.php (Estonian)

* New translations common.php (Latvian)

* New translations common.php (Bosnian)

* New translations common.php (German Informal)

* New translations settings.php (Spanish)

* New translations common.php (Chinese Simplified)

* New translations settings.php (Arabic)

* New translations settings.php (Bulgarian)

* New translations settings.php (Catalan)

* New translations settings.php (Czech)

* New translations settings.php (Danish)

* New translations settings.php (German)

* New translations settings.php (Hebrew)

* New translations settings.php (Hungarian)

* New translations settings.php (Italian)

* New translations settings.php (Japanese)

* New translations common.php (Chinese Traditional)

* New translations common.php (Turkish)

* New translations common.php (Portuguese)

* New translations common.php (Danish)

* New translations common.php (Ukrainian)

* New translations common.php (Spanish, Argentina)

* New translations common.php (Norwegian Bokmal)

* New translations settings.php (Ukrainian)

* New translations common.php (French)

* New translations common.php (Spanish)

* New translations common.php (Arabic)

* New translations common.php (Bulgarian)

* New translations common.php (Catalan)

* New translations common.php (Czech)

* New translations common.php (German)

* New translations common.php (Swedish)

* New translations common.php (Hebrew)

* New translations common.php (Hungarian)

* New translations common.php (Italian)

* New translations common.php (Korean)

* New translations common.php (Lithuanian)

* New translations common.php (Dutch)

* New translations common.php (Polish)

* New translations common.php (Russian)

* New translations common.php (Slovak)

* New translations common.php (Slovenian)

* New translations settings.php (Korean)

* New translations settings.php (Lithuanian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Norwegian Bokmal)

* New translations settings.php (Bosnian)

* New translations settings.php (Latvian)

* New translations settings.php (Estonian)

* New translations settings.php (Croatian)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Persian)

* New translations settings.php (Indonesian)

* New translations settings.php (Vietnamese)

* New translations settings.php (Dutch)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Turkish)

* New translations settings.php (Swedish)

* New translations settings.php (Slovenian)

* New translations settings.php (Slovak)

* New translations settings.php (Russian)

* New translations settings.php (Portuguese)

* New translations settings.php (Polish)

* New translations settings.php (German Informal)

* New translations common.php (Estonian)

* New translations entities.php (Estonian)

* New translations settings.php (Estonian)

* New translations common.php (Spanish)

* New translations settings.php (Spanish)

* New translations entities.php (French)

* New translations settings.php (French)

* New translations common.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations common.php (Spanish, Argentina)

* New translations settings.php (Spanish, Argentina)
2022-01-06 12:02:49 +00:00
Dan Brown
2312d07bb5 Removed old book sort permission test
Permission handling now done via other means with more extensive
permissions testing in SortTest class.
2022-01-05 16:46:03 +00:00
Dan Brown
fbd388ba4c Aligned chapter move permissions with page move permissions 2022-01-05 16:18:19 +00:00
Dan Brown
d3ca23b195 Added additional permission checks and tests for book sorts
- Aligned permissions control with move operations to check
  delete/create permissions against old/new locations.
- Added tests to cover additional permissions scenarios.
2022-01-05 15:42:59 +00:00
Dan Brown
553954ad18 Altered sort permission checking and started tests
Previous implemenations were hard to read so changing to be more
logically simplistic. Still needs further coverage in tests and
review/alignment of permissions to use.
2022-01-05 14:39:21 +00:00
Dan Brown
d8c45f5746 Changed model loading and permission checking on book sort
Models are now loaded into their own map to then be used for sorting and
reporting back of changed books. Prevents akward logic ordering issues
of before where some bits of code assumed/hoped for loaded models on
abstract data structures.

New levels of permissions are now checked for items within the
sort operation. Needs testing to cover.
2022-01-04 21:09:34 +00:00
Dan Brown
edc7c12edf Refactored sort system a little
To standardise the handled data format a little better.
2022-01-04 17:31:57 +00:00
Dan Brown
a72bd75e3a Added page titles to many missing app areas
Many pages were missing their unique tab/page titles
so this change is just to distribute them back over
many common areas where they were missing.
2022-01-04 13:33:24 +00:00
Dan Brown
31f1dca8a8 Added detection and thumbnail bypass for apng images
Adds apng sniffing when generating thumbnails with retained ratios to
serve the original image files, as we do for GIF images, to prevent
the image being resized to a static version.

Is more tricky than GIF since apng file mimes and extensions
are the same as png, we have to detect part of the file header
to sniff the type. Means we have to sniff at a later stage
than GIF since we have to load the image file data.

Made some changes to the image thubmnail caching while doing
this work to fit in with this handling.

Added test to cover.
For #3136.
2022-01-04 13:10:35 +00:00
Dan Brown
819ec55b1b Fixed code block language parsing issue
Language parsing of code blocks could falter on pasted code blocks due
to the lanuage being parsed with a space which would throw an error when
used as a css class.
This adds more extensive language parsing to be safer.

Fixes #3133
2022-01-04 11:54:24 +00:00
Dan Brown
dba506a20e Added search autofocus on entity-selector-popup
Closes #3127
2022-01-04 11:30:44 +00:00
Dan Brown
d0de4fd8f9 Fixed failing webhook test cases 2022-01-03 19:51:13 +00:00
Dan Brown
00eedafbfd Added timeout and debugging statuses to webhooks
- Added a user-configurable timeout option to webhooks.
- Added webhook fields for last-call/error datetime, in addition to last
  error string, which are shown on  webhook edit view.

Related to #3122
2022-01-03 19:42:48 +00:00
Dan Brown
6e18620a0a Added webhook call http exception handling
Will now catch and log errors on events such as http timeouts.
For #3122
2022-01-03 18:37:56 +00:00
Dan Brown
fe54c7f27a Added webhook_call_before theme event hook 2022-01-03 18:22:03 +00:00
Dan Brown
65830b428c Fixed linked images being micro on pdf export
Was caused by max-width: 100% causing confusion when images were
inside an anchor. This change resets that property on PDF
exports allowing full width images to be shown as so
without affecting smaller sizes.

Fixes #3120
2022-01-01 18:18:37 +00:00
Dan Brown
b438e0187c Updated webhooks list to not squash events/status
Closes #3135
2022-01-01 17:43:33 +00:00
Dan Brown
8614775c14 Updated sponsors in readme 2021-12-30 16:43:28 +00:00
Dan Brown
09436836a5 Updated version and assets for release v21.12 2021-12-22 17:04:18 +00:00
Dan Brown
bb455d7788 Merge branch 'master' into release 2021-12-22 17:03:50 +00:00
Dan Brown
b0666e5d70 Updated translator contribution before v21.12 release 2021-12-22 16:30:48 +00:00
Dan Brown
fc109f7e1c Applied latest StyleCI changes 2021-12-20 17:40:27 +00:00
Dan Brown
21f2a7087c Merge pull request #3118 from BookStackApp/copy_stuff
Additional copy/clone abilities
2021-12-20 17:39:44 +00:00
Dan Brown
ff70509fca Added copy considerations
Show to the user when copying stuff to highlight important things such
as what's not copied or change in permissions.
2021-12-20 17:33:19 +00:00
Dan Brown
0288320700 Added ability to clone books 2021-12-19 19:20:31 +00:00
Dan Brown
20e093a7a1 Added ability to copy/clone chapters
Builds upon page clone work. Takes permissions into account to decide
if child pages should be copied.
2021-12-19 15:40:52 +00:00
Dan Brown
3f9527f166 Extracted page copy to new cloner class
Fundemental refactor for planned additional clone operations.
No behaviour change intended in this commit.
2021-12-19 12:56:27 +00:00
Dan Brown
da01913616 Added ability to copy a role
- Copies via loading in model on create view.
- Updated role views while editing to bring up to similar format as
  that used for more modern app areas.
- Added tests to cover.

Related to #1123
2021-12-19 12:27:14 +00:00
Dan Brown
67b6c07548 Updated failing tests, Applied StyleCI changes 2021-12-18 16:41:42 +00:00
Dan Brown
bb9cd9d610 Aligned password length requirements
Updated all password validation to use central password defaults
system while updating length requirements to now all match
at 8 characters minimum.

Some language text was technically correct (More than 7 characters)
but this has been updated for clarity and to prompt other translations
to be updated.

Closes #2237
2021-12-18 16:33:40 +00:00
Dan Brown
04f37e21e2 Applied latest StyleCI changes 2021-12-18 11:43:05 +00:00
Dan Brown
a3ead5062a Merge branch 'webhooks' 2021-12-18 11:40:08 +00:00
Dan Brown
24e29c523b Aligned notification capitalisation 2021-12-18 11:24:58 +00:00
Dan Brown
04d59763c3 Updated auditlog IP search test
To ensure the test covers filtering logic.
Related to #3081.
2021-12-18 11:05:41 +00:00
Dan Brown
5c04f25c86 Merge branch 'search-by-ip' of https://github.com/johnroyer/BookStack into johnroyer-search-by-ip 2021-12-18 10:58:07 +00:00
Dan Brown
767a82fb41 Reverted unrequired use of mb_ function 2021-12-18 10:43:43 +00:00
Dan Brown
5c5a3de7cb Merge branch 'fix/multibyte-safe-search' 2021-12-18 10:40:38 +00:00
Dan Brown
c6e3e85e82 Added test case for multibyte search highlighting
Related to #3113
2021-12-18 10:38:33 +00:00
Kristian Krastev
d0fd1b7f5c Make building of search results work for multi-byte encoded characters 2021-12-15 16:29:43 +02:00
Dan Brown
009212ab80 Updated version and assets for release v21.11.3 2021-12-15 14:08:37 +00:00
Dan Brown
ba9cb591c8 Merge branch 'master' into release 2021-12-15 14:08:17 +00:00
Dan Brown
632cb71af4 Updated translator attribution before release v21.11.3 2021-12-15 14:07:54 +00:00
Dan Brown
74ab99ec41 Updated php deps 2021-12-15 14:00:30 +00:00
Dan Brown
aa9dafec85 Altered mysql start command in workflows
Due to https://github.com/actions/virtual-environments/issues/4732
2021-12-15 13:56:21 +00:00
Dan Brown
73a37b3cd9 Applied latest StyleCI changes 2021-12-15 13:49:20 +00:00
Dan Brown
e43f679e62 Merge branch 'user_list_control' 2021-12-15 13:47:48 +00:00
Dan Brown
57fc1ba38f New Crowdin updates (#3093)
* New translations auth.php (Vietnamese)

* New translations entities.php (Norwegian Bokmal)

* New translations common.php (Norwegian Bokmal)

* New translations entities.php (Norwegian Bokmal)

* New translations auth.php (Spanish, Argentina)

* New translations entities.php (Spanish, Argentina)

* New translations errors.php (Spanish, Argentina)

* New translations auth.php (Ukrainian)

* New translations auth.php (Ukrainian)

* New translations common.php (Ukrainian)

* New translations entities.php (Ukrainian)

* New translations errors.php (Ukrainian)

* New translations settings.php (Ukrainian)

* New translations validation.php (Ukrainian)

* New translations entities.php (Japanese)

* New translations common.php (Japanese)

* New translations entities.php (Japanese)

* New translations auth.php (Portuguese)

* New translations auth.php (Portuguese)

* New translations common.php (Portuguese)

* New translations entities.php (Portuguese)
2021-12-15 13:46:49 +00:00
Dan Brown
e765e61854 Addressed user detail harvesting issue
Altered access & usage of the /search/users/select endpoint with the
following changes:
- Removed searching of email address to prevent email detail discovery
  via hunting via search queries.
- Required the user to be logged in and have permission to manage users
  or manage permissions on items in some way.
- Removed the user migration option on user delete unless they have
  permission to manage users.

For #3108
Reported in https://huntr.dev/bounties/135f2d7d-ab0b-4351-99b9-889efac46fca/
Reported by @haxatron
2021-12-14 18:47:22 +00:00
Dan Brown
d00ac3101d Allowed database queue usage where desired 2021-12-13 18:34:18 +00:00
Dan Brown
f27d0d5aeb Added testing to cover webhook calling
Migrated call logic to Laravel's HTTP client for easier testing
capabilities.
2021-12-12 19:01:50 +00:00
Dan Brown
8d8b45860a Updated REST API docs with links to webhooks & theme-systems 2021-12-12 18:14:22 +00:00
Dan Brown
3bf34b6a0d Added webhook format example to webhook management views 2021-12-12 18:02:08 +00:00
Dan Brown
dbd4281ae8 Added active toggle to webhooks
To allow easy temporary de-activation without deletion or other
workarounds. Updated tests to cover.
2021-12-12 17:39:06 +00:00
Dan Brown
917598f7c8 Added webhook call functionality 2021-12-11 22:29:33 +00:00
Dan Brown
9079700170 Refactored the activity service
- Renamed to "ActivityLogger" to be more focused in usage.
- Extracted out query elements to seperate "ActivityQueries" class.
- Removed old 'addForEntity' activity method to limit activity record
  points.
2021-12-11 17:29:33 +00:00
Dan Brown
f2cb3b94f9 Added missing migration down table drop 2021-12-10 14:58:14 +00:00
Dan Brown
6381041252 Added testing for webhook management interface 2021-12-10 14:54:58 +00:00
Zero
7d13666039 Add unit test for ip addess searching 2021-12-10 15:11:30 +08:00
Zero
e6e92618b1 Fix PHP CS 2021-12-10 14:58:05 +08:00
Zero
2342f0c1c7 Fix UI error of IP searching input box 2021-12-10 14:50:04 +08:00
Zero
ee1106630e Update translation setting in blade 2021-12-10 14:50:04 +08:00
Zero
93e80e5d4e Delete duplicated translation 2021-12-10 14:50:04 +08:00
Zero
72d19968dd Search IP by partial-equal 2021-12-10 14:50:04 +08:00
Zero
2fd7b1f0d5 Update index name to 'activities_ip_index' 2021-12-10 14:50:04 +08:00
Zero
a93254430c Add index for user IP address 2021-12-10 14:50:04 +08:00
Zero
e686b2cf3c Show current search IP 2021-12-10 14:50:04 +08:00
Zero
4e63554cc6 Add an hidden submit
This hidden submit makes auto submit while user press Enter on IP
input field.
2021-12-10 14:50:04 +08:00
Zero
882f195927 Add margin right for IP input box 2021-12-10 14:50:04 +08:00
Zero
a12e346439 Add filter of user IP 2021-12-10 14:50:04 +08:00
Zero
8dee3d3a83 Add label translation 2021-12-10 14:50:04 +08:00
Zero
0e25298db9 Fix label and input box error 2021-12-10 14:50:04 +08:00
Zero
9cac6fad73 Add IP address search field mock 2021-12-10 14:50:04 +08:00
Dan Brown
8716b1922b Completed webhook management interface
Got webhook CRUD actions in place within the interface.
Quick manual test pass done, Needs automated tests.
2021-12-08 17:35:58 +00:00
Dan Brown
4621d8bcc5 Initial controller/views for webhooks management 2021-12-08 14:29:42 +00:00
Dan Brown
a3a3055695 Started webhook implementation 2021-12-07 14:55:11 +00:00
Dan Brown
867cbe15ea Added link to OIDC docs in .env.example.complete 2021-12-07 13:45:43 +00:00
Dan Brown
b22dd3cb88 Added url and preview_html params to search API results
Allows easy direct linking and usage of the HTML preview content
we show in the UI when viewing search results.
Note: preview_html content is a rough representation only, it does not
match exactly what was matched in the database-search-operation which
finds the results.

For #3096 and #3080
2021-12-06 20:42:04 +00:00
Dan Brown
d00ac2f34e Updated version and assets for release v21.11.2 2021-11-30 14:30:19 +00:00
Dan Brown
bd4dc6d463 Merge branch 'master' into release 2021-11-30 14:29:53 +00:00
Dan Brown
e6c8ecba9c Merge branch 'master' of github.com:BookStackApp/BookStack 2021-11-30 14:25:27 +00:00
Dan Brown
9490457d04 Applied StyleCI changes 2021-11-30 14:25:09 +00:00
Dan Brown
3e97fdf827 New Crowdin updates (#3076)
* New translations entities.php (Chinese Simplified)

* New translations settings.php (Portuguese, Brazilian)

* New translations validation.php (Portuguese, Brazilian)

* New translations common.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations auth.php (Turkish)
2021-11-30 14:24:35 +00:00
Dan Brown
3b3eb0f44f Updated API session auth to consider public access setting
For #3091
2021-11-30 13:55:56 +00:00
Dan Brown
b4fa82e329 Fixed related permissions query not considering drafts
Page-related items added on drafts could be visible in certain scenarios
since the applied permissions query filters would not consider
page draft visibility.
This commit alters queries on related items to apply such filtering.

Included test to cover API scenario.
Thanks to @haxatron for reporting.
2021-11-30 00:06:17 +00:00
Dan Brown
42703dd859 Tweaked pdf export iframe replacement to fix compatibility
Was using a method that wasn't a proper available part of the
DomElement API.
2021-11-28 21:01:35 +00:00
Dan Brown
2c21850da7 Added conversion of iframes to anchors on PDF export
- Replaced iframe elements with anchor elements wrapped in a paragraph.
- Extracted PDF generation action to seperate class for easier mocking
  within testing.
- Added test to cover.

For #3077
2021-11-25 15:12:32 +00:00
Dan Brown
709533c1fb Fixed up logical theme docs a tad
- Added link to video guide on YouTube.
- Formalised the customCommand docs parts I hastily added before.
2021-11-24 18:58:46 +00:00
Dan Brown
d91180a909 Updated version and assets for release v21.11.1 2021-11-23 20:44:36 +00:00
Dan Brown
bc2913a5cb Merge branch 'master' into release 2021-11-23 20:44:12 +00:00
Dan Brown
cd7788f2e9 Updated translators and merged styleci fixes 2021-11-23 20:41:12 +00:00
Dan Brown
f63d7f60aa New Crowdin updates (#3057)
* New translations auth.php (Chinese Simplified)

* New translations common.php (Chinese Simplified)

* New translations entities.php (Chinese Simplified)

* New translations common.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations activities.php (Chinese Simplified)

* New translations entities.php (Chinese Simplified)

* New translations auth.php (Russian)

* New translations common.php (Russian)

* New translations common.php (Russian)

* New translations entities.php (Russian)

* New translations errors.php (Russian)

* New translations settings.php (Russian)

* New translations auth.php (Italian)

* New translations common.php (Italian)

* New translations entities.php (Italian)

* New translations entities.php (Italian)

* New translations auth.php (Estonian)
2021-11-23 20:38:52 +00:00
Dan Brown
197caddf96 Changed homepage card header links to be bottom-card-links
The old links in the headers were not obvious. This changes the
header-based links to instead be a link at the bottom of the card.

Related to #3046
2021-11-23 18:18:49 +00:00
Dan Brown
096ed722dd Added use of the prefers-contrast media query
Used upon areas we usually fade-out to provide a focused user
experience. If the user desires more contrasted we prevent this
behaviour using the prefers-contrast media query.

Related to #2634
2021-11-23 15:49:54 +00:00
Dan Brown
024924eef3 Applied another round of static analysis updates 2021-11-22 23:33:55 +00:00
Dan Brown
1bf59f434b Tweaked custom command registration, Added StyleCI fixes
Old command registration method was interfering with default commands,
causing only a limited subset of commands to show overall.
This change follows the method the frameworks uses when loading in from a
directory to prevent issues with run/load order.
2021-11-22 22:22:31 +00:00
Dan Brown
c6e196989e Merge pull request #3072 from BookStackApp/logical_theme_commands
Support custom commands via logical theme system
2021-11-22 19:08:15 +00:00
Dan Brown
cb30c258df Added test for logical-theme-system command registration
Changed how the command registration was handled due to complications of
action order found during testing. Now the theme service will resolve
and directly register the command on the Kernel instead of them being
fetched from the ThemeService from within Kernel.
More direct, Seems to work.
2021-11-22 19:03:04 +00:00
Dan Brown
cdaad2f40e Support custom commands via logical theme system
Added initial work to support registering commands through the logical
theme system. Includes docs changes and example.

Not yet covered via testing.
2021-11-22 18:30:58 +00:00
Dan Brown
4ddbc9556b Merge branch 'assign_ids_to_nested_headers' of https://github.com/Julesdevops/BookStack into Julesdevops-assign_ids_to_nested_headers 2021-11-22 16:34:28 +00:00
Dan Brown
9a5adc026a Updated test to ensure autofocus is set on TOTP input 2021-11-22 13:28:46 +00:00
Robert Accettura
37db51a627 Update verify-totp.blade.php 2021-11-21 23:15:37 -05:00
julesdevops
f8c16494fd feat(PageContent): set unique ids on nested headers 2021-11-21 22:45:25 +01:00
Robert Accettura
0d740ca681 Set taborder for TOTP Verification
Adding tabindex=0 means when pressing tab the focus goes right to the TOTP input field.  When using a Password Manager this makes it easier than having to hit tab 3X to get the right focus.
2021-11-21 15:40:11 -05:00
Dan Brown
876bc10d4d Applied another set of static analysis improvements 2021-11-20 14:03:56 +00:00
Dan Brown
754403a29e Added video guide link to visual theme system docs 2021-11-18 21:04:25 +00:00
Dan Brown
4802394562 Updated version and assets for release v21.11 2021-11-16 13:22:24 +00:00
Dan Brown
1755556468 Merge branch 'master' into release 2021-11-16 13:21:44 +00:00
Dan Brown
05ef23d34e New Crowdin updates (#3040) 2021-11-16 12:31:37 +00:00
Dan Brown
79c75f9296 Updated translators and made StyleCI changes 2021-11-16 12:29:50 +00:00
Dan Brown
555723a966 Fixed tags listing grouping by name only on search
Included test to cover case
2021-11-15 19:00:37 +00:00
Dan Brown
056d7c119f Updated php packages 2021-11-15 18:39:38 +00:00
Dan Brown
226f296c9c Removed extra border around markdown editor box 2021-11-15 11:37:17 +00:00
Dan Brown
b546098b36 Fixed page editor back button sometimes going nowhere
Updated the back button to be a proper link instead of a reference to
the last viewed URL since it could break if the last page was the
current one (On validation for example).

Includes test to cover.
Also applied some styleCI changes.

Fixes #2834
2021-11-15 11:19:03 +00:00
Dan Brown
88e6f93abf Prevented auto-login from direct email confirmation actions
Was done for convenience but could potentially be exploited by an
attacker using signing up via one of these routes, then forwarding
an email confirmation to another user so they unknowingly utilise
an account someone else controls.

Tweaks the flow of confirming email, and the user invite flow.

For #3050
2021-11-15 10:50:28 +00:00
Dan Brown
e29d03ae76 Updated page includes to be top-level for code blocks
This change means that code blocks are now included still wrapped in
their pre tags, as we do for tables and lists.
Previously the <code> inner content would be included which would lead
to a generally bad/broken presentation.

Hopefully should not be a breaking change as section include tags for
code was tricky to get to, and it was in a semi-broken state.

For #2406
2021-11-15 00:48:05 +00:00
Dan Brown
85154fff69 Added an env configurable file upload size limit
Replaces the old suggestion of setting JS head 'window.uploadLimit'
variable. This new env option will be used by back-end validation and
front-end libs/logic too.

Limits already likely exist within prod environments at a PHP and
webserver level but this allows an app-level limit and centralises the
option on the BookStack side into the .env

Closes #3033
2021-11-14 22:03:22 +00:00
Dan Brown
f910738a80 Changed logout routes to POST instead of GET
As per #3047.

Also made some SAML specific fixes:
- IDP initiated login was broken due to forced default session value.
  Double checked against OneLogin lib docs that this reverted logic was fine.
- Changed how the saml login flow works to use 'withoutMiddleware' on
  the route instead of hacking out the session driver. This was due to
  the array driver (previously used for the hack) no longer being
  considered non-persistent.
2021-11-14 21:13:24 +00:00
Dan Brown
fceb4ecc07 Fixed sponsor image logo paths
Broke due to website branch name change
2021-11-14 16:53:01 +00:00
Dan Brown
6f1bdbf771 Added API search endpoint
Is a little awkward, emulates a 'list' API endpoint but has unstable
paging and does not support filters/sort. This is detailed on the
endpoint though.

Made some updates to the docs system to better support parameters
and examples on GET requests.

Includes tests to cover.

For #909
2021-11-14 16:28:01 +00:00
Dan Brown
2051189921 Added /api => /api/docs redirect for convenience. 2021-11-14 15:20:04 +00:00
Dan Brown
7025cb38df Removed prefix route groups, applyed styleci changes
Removing prefix route groups out of visual preference.
Those don't really save much and I prefer seeing the complete
paths when going down the list to better guage where I am.
2021-11-14 15:16:18 +00:00
Dan Brown
2e49b16177 Prevented created/update_by filters be wiped in search
Updating filters via sidebar would wipe the created_by/update_by filters
since these were not part of the sidebar filter form.
This adds them, if existing, as hidden inputs.
Includes tests to cover.

Closes #2736
2021-11-14 15:07:13 +00:00
Dan Brown
8e71cd9bac Fixed issue where markdown drafts showed as HTML
Markdown content was not being stored, only the sent
HTML representation, causing the draft to show as HTML upon next edit.
Added test to cover.

Fixes #3054
2021-11-14 12:17:22 +00:00
Dan Brown
89f7f8e259 Hid skip-to-content for print media
Fixes #3051
2021-11-14 11:50:13 +00:00
Dan Brown
f2ee95ca03 Merge pull request #3043 from BookStackApp/search_improvements_a
Search Engine Improvement
2021-11-13 15:13:29 +00:00
Dan Brown
fc7bd57dc8 Fixed occurances of altered titles in search results 2021-11-13 15:04:04 +00:00
Dan Brown
21d3620ef0 Attempted to make test a bit less flaky 2021-11-13 14:51:59 +00:00
Dan Brown
755dc99c72 Made further tweaks to search results formatting
- Updated page names to not be limited to a certain length.
- Added better start/end fill logic.
- Prevented <strong> tags from being counted towards the target content
  length desired from the formatter.
2021-11-13 14:37:40 +00:00
Dan Brown
221458ccfd Fixed failing tests due to search highlighting changes 2021-11-13 13:43:41 +00:00
Dan Brown
2633b94deb Applied StyleCI changes 2021-11-13 13:28:17 +00:00
Dan Brown
63d8d72d7e Added testing to cover search result highlighting 2021-11-13 13:26:11 +00:00
Dan Brown
339518e2a6 Added tag highlighting in search
Using basic match of name or value containing a general term.
2021-11-13 13:02:32 +00:00
Dan Brown
ab4e99bb18 Added name highlighting in search results 2021-11-13 12:44:27 +00:00
Dan Brown
f30b937bb0 Added search result preview text highlighting
Created a new class to manage formatting of content for search results.
Turned out to be quite a complex task. This only does the preview text
so far, not titles or tags.

Not yet tested.
2021-11-12 22:57:50 +00:00
Dan Brown
7d0724e288 Added auto-conversion of search terms to exact values
Will occur when a search term contains a character that's used to split
content into search terms.
Added testing to cover.
2021-11-12 18:03:44 +00:00
Dan Brown
99587a0be6 Added tag values as part of the indexed search terms
This allows finding content via tag name/values when just searching
using normal seach terms.
Added testing to cover.

Related to #1577
2021-11-12 17:06:01 +00:00
Dan Brown
f28daa01d9 Added page content parsing to up-rank header text in search
This adds parsing of page content so that headers apply a boost to
scores in the search term index.
Additionally, this merges title and content terms to reduce the amount
of stored terms a little.
Includes testing to cover.
2021-11-12 13:47:23 +00:00
Dan Brown
820be162f5 Updated regen-search command to show some level of progress 2021-11-11 14:10:11 +00:00
Dan Brown
9f32613982 Refactored search indexer, Increase title/name score boost
- Title score boost changed from 5 to 40 (8x increase).
- Extracted entity parsing to its own function
2021-11-11 13:36:49 +00:00
Dan Brown
0ddd052818 Added missing comments or types
Checked over latest changes for potential SQL injection, all variable
usages are either (from trusted sourced AND case) or using
parameters/bindings to ensure it's handled at driver/lib level.
2021-11-09 15:13:15 +00:00
Dan Brown
da17004c3e Added test to cover search frquency rank changes 2021-11-09 15:05:02 +00:00
Dan Brown
bc472ca2d7 Improved relation loading during search
Relations now loaded during back-end query phase instead of being lazy
loaded one-by-one within views.

Reduced queries in testing from ~60 to ~20.

Need to check other areas list-item.php's "showPath" option is used to
ensure relations are properly loaded for those listings.
2021-11-08 15:24:49 +00:00
Dan Brown
b3e1c7da73 Applied styleci fixes and pluck improvement as per larastan 2021-11-08 15:00:47 +00:00
Dan Brown
7405613f8d Added search term score popularity adjustment
Adds adjustment of search term 'score' (Using in result ranking) so that
a relative 0.3 to 1.3 mulitplier is applied based upon relative
popularity within the whole database. At this point the term popularity
is still done via a prefix match against the search term.

Uses a SUM(IF(cond, a, IF(cond, a, ...))) chain to produce the scoring
result in the select query.
2021-11-08 14:23:48 +00:00
Dan Brown
b0b6f466c1 Reduced data retreived from database on page search 2021-11-08 11:41:14 +00:00
Dan Brown
9e0164f4f4 Further search system refactorings
- Moved search term querying to its own method.
- Updated Large content seeder to be more performant
2021-11-08 11:29:25 +00:00
Dan Brown
e1b8fe45b0 Refactored search runner a little to be neater 2021-11-08 11:04:27 +00:00
Dan Brown
f2b1d2e1e7 Applied latest StyleCI changes 2021-11-06 22:00:33 +00:00
Dan Brown
921e25e7e1 Merge pull request #3042 from BookStackApp/tags_view
Tag view
2021-11-06 21:59:34 +00:00
Dan Brown
899349c4b4 Added testing coverage for tag index
Also:
- Extracted out index table row to its own view.
- Added empty state.
- Ensured query params are set on pagination links.
2021-11-06 21:54:02 +00:00
Dan Brown
f8f9e74992 Added links to tag page
- Added from books/shelves listings and within the tag-edit view for all
  entities.
2021-11-06 20:21:11 +00:00
Dan Brown
929c8312bd Started build of tag view
- Created listing
- Allows drilldown to tag name
- Shows totals

Not yet covered via testing
2021-11-06 16:30:20 +00:00
Dan Brown
8d7c8ac8bf Done a round of phpstan fixes 2021-11-06 00:32:01 +00:00
Dan Brown
5c6a6b50a0 Applied StyleCI changes, added php/larastan to attribution 2021-11-05 16:27:59 +00:00
Dan Brown
bc291bee78 Added inital phpstan/larastan setup 2021-11-05 16:18:06 +00:00
Dan Brown
d0aa10a8c3 Applied styleci changes 2021-11-05 00:28:41 +00:00
Dan Brown
06b5009842 Standardised laravel validation to be array based
Converted from string-only-based validation.
Array based validation works nicer once you have validation classess or
advanced validation options.
2021-11-05 00:26:55 +00:00
Dan Brown
0ba8541370 Updated npm deps 2021-11-04 23:07:36 +00:00
Dan Brown
22024df508 Merge branch 'master' of github.com:BookStackApp/BookStack 2021-11-04 22:58:15 +00:00
Dan Brown
de5322288c Applied latest styleci changes 2021-11-04 22:57:49 +00:00
Dan Brown
9542509584 New Crowdin updates (#3038)
Just crowdin aligning string quote styles
2021-11-04 22:57:04 +00:00
Dan Brown
1eed8d6325 Removed style in discord logo to prevent clash with twitter logo
Both were using the same class names causing a quadrant of the slack logo
to be the discord brand color.

Related to #3032
2021-11-04 22:52:35 +00:00
Dan Brown
b9a58859a4 Merge branch 'modernize-3rd-party-service-logos' of https://github.com/na3shkw/BookStack into na3shkw-modernize-3rd-party-service-logos 2021-11-04 22:45:57 +00:00
Dan Brown
c9c4dbcb5b Merge branch 'laravel_upgrade' 2021-11-04 22:42:35 +00:00
Dan Brown
6f75aa9cdc Reverted shift change to old migration 2021-11-04 22:38:55 +00:00
Dan Brown
9c680efaad Updated php packages, Added php8.1 to GH actions 2021-11-04 22:29:36 +00:00
Dan Brown
cccee0808f Updated API examples with date format changes
Updated to full ISO-8601 to reflect change in Laravel 7.
2021-11-04 22:02:21 +00:00
Dan Brown
01cdbdb7ae Updated version and assets for release v21.10.3 2021-11-01 13:31:10 +00:00
Dan Brown
fc8bbf3eab Merge branch 'master' into release 2021-11-01 13:30:36 +00:00
Dan Brown
a17be959d8 Applied latest styleci changes 2021-11-01 13:26:02 +00:00
Dan Brown
ce3f489188 Merge branch '3027_attachment_vuln' 2021-11-01 13:25:12 +00:00
Dan Brown
f4201e5740 New Crowdin updates (#3023)
* New translations errors.php (Polish)

* New translations activities.php (Dutch)

* New translations auth.php (Dutch)

* New translations common.php (Dutch)

* New translations entities.php (Dutch)

* New translations auth.php (Dutch)

* New translations auth.php (Dutch)

* New translations auth.php (Dutch)

* New translations settings.php (Latvian)
2021-11-01 13:16:15 +00:00
na3shkw
7e2c1b31a1 Modernize third party services' logos 2021-11-01 12:41:23 +00:00
Dan Brown
bfbccbede1 Updated attachments to not be saved with a complete extension
Intended to limit impact in the event the storage path is potentially
exposed.
2021-11-01 11:32:00 +00:00
Dan Brown
4360da03d4 Ran a pass through image and attachment routes
Added some stronger types, formatting changes and simplifications along
the way.
2021-11-01 11:17:30 +00:00
Dan Brown
c7fea8fe08 Cleaned up logic within ImageRepo
- Moved out extension check to ImageService as that seems more relevant.
- Updated models to use static-style references instead of facade to align with common modern usage within the app.
- Updated custom image_extension validation rule to use shared logic in image service.
2021-11-01 00:24:42 +00:00
Dan Brown
43830a372f Updated showImage file serving to not be traversable
For #3030
2021-10-31 23:53:17 +00:00
Dan Brown
ae155d6745 Added safe mime sniffing to prevent serving HTML
(Amoung other content types)
For #3027
2021-10-31 17:58:56 +00:00
Dan Brown
5c834f24a6 Updated AzureAD provider to use microsoft graph
Since AzureAD graph is going away.
Tested using old AzureAD graph usage for backwards-compatbility, did not
seem to break things. Could not test with conditional access though due
to azure never enforcing it no matter what I attempted.

Fpr #3028
2021-10-31 13:09:30 +00:00
Dan Brown
98b23fd7ab Moved from debugbar to clockwork 2021-10-30 22:03:36 +01:00
Dan Brown
f139cded78 Laravel 8 shift squash & merge (#3029)
* Temporarily moved back config path
* Apply Laravel coding style
* Shift exception handler
* Shift HTTP kernel and middleware
* Shift service providers
* Convert options array to fluent methods
* Shift to class based routes
* Shift console routes
* Ignore temporary framework files
* Shift to class based factories
* Namespace seeders
* Shift PSR-4 autoloading
* Shift config files
* Default config files
* Shift Laravel dependencies
* Shift return type of base TestCase methods
* Shift cleanup
* Applied stylci style changes
* Reverted config files location
* Applied manual changes to Laravel 8 shift

Co-authored-by: Shift <shift@laravelshift.com>
2021-10-30 21:29:59 +01:00
Dan Brown
85dc8d9791 Updated sponsor link 2021-10-30 11:51:49 +01:00
Dan Brown
5fd10e695a Added sponsors to readme, updated license file 2021-10-29 21:37:10 +01:00
Dan Brown
3cdab19319 Updated version and assets for release v21.10.2 2021-10-28 15:57:04 +01:00
Dan Brown
5661d20e87 Merge branch 'master' into release 2021-10-28 15:56:49 +01:00
Dan Brown
e7bec79f25 New Crowdin updates (#3014)
* New translations entities.php (Estonian)

* New translations entities.php (Estonian)
2021-10-28 15:55:13 +01:00
Dan Brown
4f55fe2f8e Made further changes to page image extraction validation
Fixes #3019
Increased testing to cover the failing case amoung others.
2021-10-28 15:54:00 +01:00
Dan Brown
91f80123e8 Merge branch 'master' into release 2021-10-27 12:35:00 +01:00
Dan Brown
7a0636d0f8 Updated version and assets for release v21.10.1 2021-10-27 12:31:40 +01:00
Dan Brown
3166541002 Added test to cover #3010 2021-10-27 12:29:01 +01:00
Dan Brown
b31fbf5ba8 Merge branch 'master' of https://github.com/haxatron/BookStack into haxatron_upload_issue 2021-10-27 12:21:27 +01:00
Dan Brown
624d55a773 New Crowdin updates (#3006)
* New translations auth.php (Latvian)

* New translations errors.php (Latvian)

* New translations auth.php (Latvian)

* New translations entities.php (Latvian)

* New translations settings.php (Latvian)

* New translations settings.php (Estonian)

* New translations entities.php (Estonian)

* New translations settings.php (Estonian)

* New translations validation.php (Estonian)

* New translations entities.php (Estonian)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Vietnamese)

* New translations settings.php (Slovenian)

* New translations settings.php (Swedish)

* New translations settings.php (Turkish)

* New translations settings.php (Ukrainian)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Russian)

* New translations settings.php (Indonesian)

* New translations settings.php (Persian)

* New translations settings.php (Croatian)

* New translations settings.php (Latvian)

* New translations settings.php (Bosnian)

* New translations settings.php (Norwegian Bokmal)

* New translations settings.php (Slovak)

* New translations settings.php (Portuguese)

* New translations settings.php (Polish)

* New translations settings.php (Catalan)

* New translations settings.php (Estonian)

* New translations settings.php (Japanese)

* New translations settings.php (French)

* New translations settings.php (Spanish)

* New translations settings.php (Arabic)

* New translations settings.php (Bulgarian)

* New translations settings.php (Czech)

* New translations settings.php (Dutch)

* New translations settings.php (Danish)

* New translations settings.php (German)

* New translations settings.php (Hebrew)

* New translations settings.php (Hungarian)

* New translations settings.php (Italian)

* New translations settings.php (Korean)

* New translations settings.php (Lithuanian)

* New translations settings.php (German Informal)

* New translations settings.php (Polish)

* New translations settings.php (French)

* New translations settings.php (German)

* New translations settings.php (German Informal)
2021-10-27 12:17:53 +01:00
Dan Brown
f77236aa38 Laravel 7.x Shift (#3011)
* Apply Laravel coding style
* Shift bindings
* Shift core files
* Shift to Throwable
* Add laravel/ui dependency
* Shift Eloquent methods
* Shift config files
* Shift Laravel dependencies
* Shift cleanup
* Shift test config and references
* Applied styleci changes
* Applied fixes post shift to laravel 7

Co-authored-by: Shift <shift@laravelshift.com>
2021-10-26 22:04:18 +01:00
Dan Brown
42f0ba1875 Added security policy md file 2021-10-26 16:09:41 +01:00
Dan Brown
0d312e5348 Merge pull request #3008 from IndrekHaav/et-typo
Minor capitalisation fix for Estonian
2021-10-26 13:33:27 +01:00
Dan Brown
7b244ea012 Updated php deps
Also removes abandoned status of sebastian/resource-operations as per
issue #3007
2021-10-26 13:12:40 +01:00
Indrek Haav
538b5ef4eb Minor capitalisation fix for Estonian 2021-10-26 15:09:38 +03:00
Haxatron
64937ab826 Update ImageRepo.php
fix image validation vulnerability
2021-10-26 09:39:16 +08:00
Dan Brown
0fe5bdfbac Updated version and assets for release v21.10 2021-10-25 15:59:23 +01:00
Dan Brown
f88687e977 Merge branch 'master' into release 2021-10-25 15:58:59 +01:00
Dan Brown
a5401eb00a New Crowdin updates (#3005)
* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Polish)

* New translations settings.php (Estonian)

* New translations errors.php (Spanish, Argentina)

* New translations settings.php (Japanese)

* New translations activities.php (German Informal)

* New translations auth.php (German Informal)

* New translations settings.php (French)

* New translations settings.php (Spanish)

* New translations settings.php (Arabic)

* New translations settings.php (Bulgarian)

* New translations settings.php (Catalan)

* New translations settings.php (Norwegian Bokmal)

* New translations settings.php (German Informal)

* New translations settings.php (Bosnian)

* New translations settings.php (Czech)

* New translations settings.php (Slovak)

* New translations settings.php (Danish)

* New translations settings.php (German)

* New translations settings.php (Hebrew)

* New translations settings.php (Hungarian)

* New translations settings.php (Italian)

* New translations settings.php (Korean)

* New translations settings.php (Lithuanian)

* New translations settings.php (Dutch)

* New translations settings.php (Portuguese)

* New translations settings.php (Russian)

* New translations settings.php (Slovenian)

* New translations settings.php (Latvian)

* New translations settings.php (Swedish)

* New translations settings.php (Turkish)

* New translations settings.php (Ukrainian)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Vietnamese)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Indonesian)

* New translations settings.php (Persian)

* New translations settings.php (Croatian)

* New translations validation.php (German Informal)
2021-10-25 15:01:32 +01:00
Dan Brown
fa466139f0 Updated translators before v21.10 release 2021-10-25 14:49:21 +01:00
Dan Brown
a75cfd1f25 Added estonian to language logic 2021-10-25 14:49:03 +01:00
Dan Brown
9c2b8057ab New Crowdin updates (#2983)
* New translations auth.php (Polish)

* New translations common.php (Polish)

* New translations entities.php (Polish)

* New translations auth.php (Polish)

* New translations common.php (Polish)

* New translations settings.php (Polish)

* New translations validation.php (Polish)

* New translations activities.php (Estonian)

* New translations auth.php (Estonian)

* New translations common.php (Estonian)

* New translations components.php (Estonian)

* New translations entities.php (Estonian)

* New translations errors.php (Estonian)

* New translations pagination.php (Estonian)

* New translations passwords.php (Estonian)

* New translations settings.php (Estonian)

* New translations validation.php (Estonian)

* New translations activities.php (Estonian)

* New translations activities.php (Estonian)

* New translations auth.php (Estonian)

* New translations common.php (Estonian)

* New translations components.php (Estonian)

* New translations entities.php (Estonian)

* New translations pagination.php (Estonian)

* New translations passwords.php (Estonian)

* New translations entities.php (Estonian)

* New translations errors.php (Estonian)

* New translations validation.php (Estonian)

* New translations settings.php (Estonian)

* New translations auth.php (Estonian)

* New translations entities.php (Estonian)

* New translations passwords.php (Estonian)

* New translations settings.php (Estonian)

* New translations auth.php (Estonian)

* New translations entities.php (Estonian)

* New translations errors.php (Estonian)

* New translations settings.php (Estonian)

* New translations settings.php (Estonian)

* New translations errors.php (German)

* New translations errors.php (Portuguese, Brazilian)

* New translations errors.php (Swedish)

* New translations errors.php (Turkish)

* New translations errors.php (Ukrainian)

* New translations errors.php (Chinese Simplified)

* New translations errors.php (Chinese Traditional)

* New translations errors.php (Vietnamese)

* New translations errors.php (Indonesian)

* New translations errors.php (Slovak)

* New translations errors.php (Persian)

* New translations errors.php (Spanish, Argentina)

* New translations errors.php (Croatian)

* New translations errors.php (Latvian)

* New translations errors.php (Bosnian)

* New translations errors.php (Norwegian Bokmal)

* New translations errors.php (Slovenian)

* New translations errors.php (Russian)

* New translations errors.php (Estonian)

* New translations errors.php (Danish)

* New translations errors.php (French)

* New translations errors.php (Spanish)

* New translations errors.php (Arabic)

* New translations errors.php (Bulgarian)

* New translations errors.php (Catalan)

* New translations errors.php (Czech)

* New translations errors.php (Hebrew)

* New translations errors.php (Portuguese)

* New translations errors.php (Hungarian)

* New translations errors.php (Italian)

* New translations errors.php (Japanese)

* New translations errors.php (Korean)

* New translations errors.php (Lithuanian)

* New translations errors.php (Dutch)

* New translations errors.php (Polish)

* New translations errors.php (German Informal)

* New translations errors.php (Spanish)

* New translations auth.php (Estonian)

* New translations entities.php (Estonian)

* New translations errors.php (Estonian)

* New translations activities.php (Japanese)

* New translations activities.php (Japanese)

* New translations auth.php (Japanese)

* New translations components.php (Japanese)

* New translations passwords.php (Japanese)

* New translations errors.php (Estonian)

* New translations settings.php (Estonian)

* New translations validation.php (Estonian)

* New translations errors.php (French)

* New translations activities.php (Japanese)

* New translations settings.php (Japanese)

* New translations entities.php (Japanese)

* New translations settings.php (Japanese)

* New translations common.php (Japanese)

* New translations settings.php (Japanese)

* New translations settings.php (Japanese)

* New translations entities.php (Japanese)

* New translations settings.php (Japanese)

* New translations settings.php (Japanese)

* New translations entities.php (Japanese)

* New translations settings.php (Japanese)

* New translations common.php (Japanese)

* New translations errors.php (Polish)

* New translations auth.php (Estonian)

* New translations components.php (Estonian)

* New translations entities.php (Estonian)

* New translations validation.php (Estonian)

* New translations errors.php (Estonian)

* New translations settings.php (Estonian)

* New translations errors.php (Chinese Simplified)

* New translations auth.php (Japanese)

* New translations auth.php (Japanese)

* New translations common.php (Japanese)

* New translations entities.php (Japanese)

* New translations errors.php (Italian)

* New translations common.php (Japanese)

* New translations auth.php (Italian)

* New translations entities.php (Italian)

* New translations entities.php (Japanese)

* New translations settings.php (Japanese)

* New translations common.php (Japanese)

* New translations entities.php (Japanese)

* New translations entities.php (Estonian)

* New translations settings.php (Estonian)

* New translations validation.php (Japanese)

* New translations errors.php (Japanese)

* New translations validation.php (Japanese)

* New translations auth.php (Japanese)

* New translations settings.php (Japanese)

* New translations activities.php (Indonesian)

* New translations auth.php (Indonesian)

* New translations validation.php (Estonian)

* New translations settings.php (Estonian)
2021-10-25 13:51:27 +01:00
Dan Brown
31ba972cfc Tweaked sidepart list item padding, Review of #3000
- Scoped padding change to just entity-list-items within the sidebar
  side reduction of right-hand-padding to zero was causing other
  entity-list-items, such as those in the homepage listing, would then
  have no padding.
- Updated styles to use css logical properties to retain support for RTL
  languages such as Arabic, where the whole interface flips around.
  Related: https://css-tricks.com/css-logical-properties-and-values/
2021-10-23 22:03:03 +01:00
Dan Brown
f73b82ee57 Merge branch 'fix_sidebar_css' of https://github.com/ffranchina/BookStack into ffranchina-fix_sidebar_css 2021-10-23 21:54:25 +01:00
Dan Brown
98072ba4a9 Reviewed SAML SLS changes for ADFS, #2902
- Migrated env usages to config.
- Removed potentially unneeded config options or auto-set signed options
  based upon provision of certificate.
- Aligned SP certificate env option naming with similar IDP option.

Tested via AFDS on windows server 2019. To test on other providers.
2021-10-23 17:26:01 +01:00
Francesco Franchina
0b15e2bf1c Fixes padding issues of the sidebar's items 2021-10-22 01:34:41 +02:00
Dan Brown
2e9ac21b38 Merge branch 'master' of https://github.com/theodor-franke/BookStack into theodor-franke-master 2021-10-21 14:04:23 +01:00
Dan Brown
129f3286d9 Applied styleci changes 2021-10-20 13:40:27 +01:00
Dan Brown
fe07cdaa06 Merge pull request #2996 from BookStackApp/saml2_acs_session
Updated SAML ACS post to retain user session
2021-10-20 13:38:35 +01:00
Dan Brown
cdef1b3ab0 Updated SAML ACS post to retain user session
Session was being lost due to the callback POST request cookies
not being provided due to samesite=lax. This instead adds an additional
hop in the flow to route the request via a GET request so the session is
retained. SAML POST data is stored encrypted in cache via a unique ID
then pulled out straight afterwards, and restored into POST for the SAML
toolkit to validate.

Updated testing to cover.
2021-10-20 13:34:00 +01:00
Dan Brown
859934d6a3 Applied latest changes from styleCI 2021-10-20 10:49:45 +01:00
Dan Brown
7bbcaa7cbc Merge pull request #2986 from BookStackApp/attachments_api
Attachments API
2021-10-20 10:46:35 +01:00
Dan Brown
7e28c76e6f Adjusted API docs table 2021-10-20 10:46:06 +01:00
Dan Brown
60d4c5902b Added attachment API examples during manual testing 2021-10-20 10:43:03 +01:00
Dan Brown
2409d1850f Added TestCase for attachments API methods 2021-10-20 00:58:56 +01:00
Dan Brown
c699f176bc Fixed bug report yaml formatting 2021-10-19 15:15:35 +01:00
Dan Brown
72ad87b123 Update support_request.yml 2021-10-19 14:52:00 +01:00
Dan Brown
5d6d7ef5a7 Converted issues templates to forms
Added support request template
2021-10-19 14:49:49 +01:00
Dan Brown
7ad98fc3c3 Update language_request.yml 2021-10-19 14:07:45 +01:00
Dan Brown
0d6f1638fe Delete language_request.md 2021-10-19 14:06:53 +01:00
Dan Brown
5a4b366e56 Create language_request.yml 2021-10-19 14:05:34 +01:00
Dan Brown
32f6ea946f Build out core attachments API controller
Related to #2942
2021-10-18 17:46:55 +01:00
Dan Brown
1a8a6c609a Added phpseclib to readme 2021-10-18 11:43:54 +01:00
Dan Brown
cb45c53029 Added base64 image extraction to markdown page content
- Included tests to cover.
- Manually tested via API update and interface page update.

Closes #2898
2021-10-18 11:42:50 +01:00
Dan Brown
6e325de226 Applied latest styles changes from style CI 2021-10-16 16:01:59 +01:00
Dan Brown
263384cf99 Merge branch 'oidc' 2021-10-16 15:51:13 +01:00
Dan Brown
68d437d05b Updated version and assets for release v21.08.6 2021-10-15 14:34:44 +01:00
Dan Brown
1e56aaea04 Merge branch 'master' into release 2021-10-15 14:34:23 +01:00
Dan Brown
5ba964b677 Updated readme with latest version info
Also updated version file to be current
2021-10-15 14:30:49 +01:00
Dan Brown
5647a8a091 New Crowdin updates (#2980)
* New translations entities.php (Spanish, Argentina)

* New translations activities.php (Spanish, Argentina)

* New translations auth.php (Spanish, Argentina)

* New translations settings.php (Spanish, Argentina)

* New translations validation.php (Spanish, Argentina)

* New translations auth.php (Spanish, Argentina)
2021-10-15 14:17:32 +01:00
Dan Brown
f3c147d33b Applied latest styleci changes 2021-10-15 14:16:45 +01:00
Dan Brown
747f81d5d8 Updated php dependancies 2021-10-15 13:15:32 +01:00
Dan Brown
c9c0e5e16f Fixed guest user email showing in TOTP setup url
- Occured during enforced MFA setup upon login.
- Added test to cover.

Fixes #2971
2021-10-14 18:02:16 +01:00
Dan Brown
d21b60079c Merge pull request #2977 from BookStackApp/custom_debug_view
Added custom whoops-based debug view
2021-10-14 17:41:06 +01:00
Dan Brown
ffa4377e65 Added testing to cover debug view 2021-10-14 17:40:22 +01:00
Dan Brown
9b8bb49a33 Added custom whoops-based debug view
Provides a simple bookstack focused view that does not rely on JavaScript.
Contains links to BookStack specific resources in addition to commonly
desired debug details.
2021-10-14 15:33:08 +01:00
Dan Brown
855409bc4f Fixed lack of oidc discovery filtering during testing
Tested oidc system on okta, Keycloak & Auth0
2021-10-14 13:37:55 +01:00
Dan Brown
a5d72aa458 Fleshed out testing for OIDC system 2021-10-13 16:51:27 +01:00
Dan Brown
c167f40af3 Renamed OIDC files to all be aligned 2021-10-12 23:04:28 +01:00
Dan Brown
06a0d829c8 Added OIDC basic autodiscovery support 2021-10-12 23:00:52 +01:00
Dan Brown
790723dfc5 Added further OIDC core class testing 2021-10-12 16:48:54 +01:00
Dan Brown
f3d54e4a2d Added positive test case for OIDC implementation
- To continue coverage and spec cases next.
2021-10-12 00:01:51 +01:00
Dan Brown
6b182a435a Got OIDC custom solution to a functional state
- Validation of all key/token elements now in place.
- Signing key system updated to work with jwk-style array or with
  file:// path to pem key.
2021-10-11 23:00:45 +01:00
Dan Brown
8c01c55684 Added token and key handling elements for oidc jwt
- Got basic signing support and structure checking done.
- Need to run through actual claim checking before providing details
  back to app.
2021-10-11 19:05:16 +01:00
Dan Brown
69301f7575 Merge pull request #2965 from Haxatron/master
Update DOMPDF chroot directory
2021-10-11 10:25:28 +01:00
Dan Brown
8ce696dff6 Started on a custom oidc oauth provider 2021-10-10 19:14:08 +01:00
Haxatron
b043257d9a Update dompdf.php
base_path => public_path
2021-10-10 01:06:08 +08:00
Dan Brown
ca764caf2d Added throttling to password reset requests 2021-10-08 23:19:37 +01:00
Dan Brown
dab170a6fe Updated version and assets for release v21.08.5 2021-10-08 22:25:36 +01:00
Dan Brown
a8de717d9b Merge branch 'master' into release 2021-10-08 22:25:05 +01:00
Dan Brown
543ea6ef71 Updated translator attribution before release v21.08.5 2021-10-08 22:24:32 +01:00
Dan Brown
a9b3df537f Applied changes from styleci 2021-10-08 22:23:17 +01:00
Dan Brown
c2339ac9db New Crowdin updates (#2953)
* New translations settings.php (Chinese Simplified)

* New translations entities.php (Slovak)

* New translations entities.php (Portuguese, Brazilian)

* New translations entities.php (Slovenian)

* New translations entities.php (Swedish)

* New translations entities.php (Turkish)

* New translations entities.php (Ukrainian)

* New translations entities.php (Chinese Simplified)

* New translations entities.php (Chinese Traditional)

* New translations entities.php (Indonesian)

* New translations entities.php (Portuguese)

* New translations entities.php (Persian)

* New translations entities.php (Spanish, Argentina)

* New translations entities.php (Croatian)

* New translations entities.php (Latvian)

* New translations entities.php (Bosnian)

* New translations entities.php (Norwegian Bokmal)

* New translations entities.php (Russian)

* New translations entities.php (Polish)

* New translations entities.php (Vietnamese)

* New translations entities.php (Danish)

* New translations entities.php (French)

* New translations entities.php (Spanish)

* New translations entities.php (Arabic)

* New translations entities.php (Bulgarian)

* New translations entities.php (Catalan)

* New translations entities.php (Czech)

* New translations entities.php (German)

* New translations entities.php (Dutch)

* New translations entities.php (Hebrew)

* New translations entities.php (Hungarian)

* New translations entities.php (Italian)

* New translations entities.php (Japanese)

* New translations entities.php (Korean)

* New translations entities.php (Lithuanian)

* New translations entities.php (German Informal)

* New translations entities.php (French)

* New translations entities.php (Spanish)

* New translations settings.php (Czech)

* New translations entities.php (Czech)

* New translations activities.php (Czech)

* New translations auth.php (Czech)

* New translations common.php (Czech)

* New translations validation.php (Czech)

* New translations entities.php (Portuguese)

* New translations settings.php (Portuguese)

* New translations entities.php (Portuguese)

* New translations activities.php (Portuguese)

* New translations auth.php (Portuguese)

* New translations common.php (Portuguese)

* New translations validation.php (Portuguese)

* New translations entities.php (Chinese Simplified)

* New translations entities.php (Chinese Simplified)

* New translations activities.php (Ukrainian)

* New translations activities.php (Ukrainian)
2021-10-08 22:22:01 +01:00
Dan Brown
41541df6ec Added testing to cover work done in last commit
Relevant to comments in 7224fbcc89.
Added test cases. Ensured they failed pre-commit.
Also tested a range of the altered endpoints manually on both local and
s3-like filesystems.
2021-10-08 21:47:59 +01:00
Dan Brown
7224fbcc89 Added protections against path traversal in file system operations
- Files within the storage/ path could be accessed via path traversal
  references in content, accessed upon HTML export.
- This addresses this via two layers:
  - Scoped local flysystem filesystems down to the specific image &
    file folders since flysystem has built-in checking against the
    escaping of the root folder.
  - Added path normalization before enforcement of uploads/{images,file}
    prefix to prevent traversal at a path level.

Thanks to @Haxatron via huntr.dev for discovery and reporting.
Ref: https://huntr.dev/bounties/ac268a17-72b5-446f-a09a-9945ef58607a/
2021-10-08 17:47:14 +01:00
Dan Brown
81d6b1b016 Fixed search query issues when table prefixes are used
- Old raw select query was causing bad select clause in query
  when table prefixes were active.
2021-10-08 15:25:12 +01:00
Dan Brown
41ac69adb1 Forced response cache revalidation on logged-in responses
- Prevents authenticated responses being visible when back button
  pressed in browser.
- Previously, 'no-cache, private' was added by default by Symfony which
  would have prevents proxy cache issues but this adds no-store and a
  max-age option to also invalidate all caching.

Thanks to @haxatron via huntr.dev
Ref: https://huntr.dev/bounties/6cda9df9-4987-4e1c-b48f-855b6901ef53/
2021-10-08 15:22:09 +01:00
Dan Brown
41438adbd1 Continued review of #2169
- Removed uneeded custom refresh or logout actions for OIDC.
- Restructured how the services and guards are setup for external auth
  systems. SAML2 and OIDC now directly share a lot more logic.
- Renamed any OpenId references to OIDC or OpenIdConnect
- Removed non-required CSRF excemption for OIDC

Not tested, Come to roadblock due to lack of PHP8 support in upstream
dependancies. Certificate was deemed to be non-valid on every test
attempt due to changes in PHP8.
2021-10-06 23:05:26 +01:00
Dan Brown
2ec0aa85ca Started refactor for merge of OIDC
- Made oidc config more generic to not be overly reliant on the library
  based upon learnings from saml2 auth.
- Removed any settings that are redundant or not deemed required for
  initial implementation.
- Reduced some methods down where not needed.
- Renamed OpenID to OIDC
- Updated .env.example.complete to align with all options and their
  defaults

Related to #2169
2021-10-06 17:12:01 +01:00
Dan Brown
193d7fb3fe Merge branch 'openid' of https://github.com/jasperweyne/BookStack into jasperweyne-openid 2021-10-06 13:18:21 +01:00
Dan Brown
55be75dee2 Merge pull request #2957 from BookStackApp/dependabot/composer/composer/composer-2.1.9
Bump composer/composer from 2.1.8 to 2.1.9
2021-10-06 10:52:02 +01:00
dependabot[bot]
644bbebb6e Bump composer/composer from 2.1.8 to 2.1.9
Bumps [composer/composer](https://github.com/composer/composer) from 2.1.8 to 2.1.9.
- [Release notes](https://github.com/composer/composer/releases)
- [Changelog](https://github.com/composer/composer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/composer/composer/compare/2.1.8...2.1.9)

---
updated-dependencies:
- dependency-name: composer/composer
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-10-05 20:57:31 +00:00
Dan Brown
f99af807d0 Reviewed and refactored additional editor draft save warnings
- Added testing to cover warning cases.
- Refactored logic to be simpler and move much of the business out of
  the controller.
- Added new message that's more suitable to the case this was handling.
- For detecting an outdated draft, checked the draft created_at time
  instead of updated_at to better fit the scenario being checked.
- Updated some method types to align with those potentially being used
  in the logic of the code.
- Added a cache of shown messages on the front-end to prevent them
  re-showing on every save during the session, even if dismissed.
2021-10-04 20:26:55 +01:00
Dan Brown
756b55bbff Merge branch 'conflict_warnings' of https://github.com/MatthieuParis/BookStack into MatthieuParis-conflict_warnings 2021-10-04 17:10:40 +01:00
Dan Brown
78fe95b6fc Updated version and assets for release v21.08.4 2021-10-04 16:25:24 +01:00
Dan Brown
e0c24e41aa Merge branch 'master' into release 2021-10-04 16:24:54 +01:00
Dan Brown
e37bbf2925 Updated translator attribution before release v21.08.4 2021-10-04 16:24:17 +01:00
Dan Brown
ec61e45a2b New Crowdin updates (#2926)
* New translations settings.php (French)

* New translations auth.php (French)

* New translations settings.php (French)

* New translations entities.php (French)

* New translations activities.php (French)

* New translations common.php (French)

* New translations entities.php (French)

* New translations common.php (French)

* New translations components.php (French)

* New translations settings.php (French)

* New translations auth.php (French)

* New translations settings.php (Russian)

* New translations validation.php (Russian)

* New translations settings.php (Russian)

* New translations auth.php (Russian)

* New translations settings.php (Russian)

* New translations auth.php (Russian)

* New translations entities.php (French)

* New translations auth.php (French)

* New translations entities.php (French)

* New translations auth.php (French)

* New translations settings.php (French)

* New translations validation.php (French)

* New translations settings.php (French)

* New translations entities.php (French)

* New translations errors.php (French)

* New translations passwords.php (French)

* New translations settings.php (French)

* New translations entities.php (French)

* New translations settings.php (French)

* New translations entities.php (German)

* New translations settings.php (German)

* New translations entities.php (German Informal)

* New translations settings.php (German Informal)

* New translations settings.php (German)

* New translations settings.php (German Informal)

* New translations settings.php (French)

* New translations settings.php (Vietnamese)

* New translations settings.php (Slovenian)

* New translations settings.php (Swedish)

* New translations settings.php (Turkish)

* New translations settings.php (Ukrainian)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese)

* New translations settings.php (Indonesian)

* New translations settings.php (Persian)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Croatian)

* New translations settings.php (Latvian)

* New translations settings.php (Bosnian)

* New translations settings.php (Slovak)

* New translations settings.php (Polish)

* New translations settings.php (Russian)

* New translations settings.php (Czech)

* New translations settings.php (German)

* New translations settings.php (German Informal)

* New translations settings.php (Spanish)

* New translations settings.php (Arabic)

* New translations settings.php (Bulgarian)

* New translations settings.php (Catalan)

* New translations settings.php (Danish)

* New translations settings.php (Dutch)

* New translations settings.php (Hebrew)

* New translations settings.php (Hungarian)

* New translations settings.php (Italian)

* New translations settings.php (Japanese)

* New translations settings.php (Korean)

* New translations settings.php (Lithuanian)

* New translations settings.php (Norwegian Bokmal)

* New translations settings.php (Spanish)

* New translations activities.php (Slovak)

* New translations errors.php (Slovak)

* New translations settings.php (Slovak)

* New translations auth.php (Slovak)

* New translations common.php (Slovak)

* New translations entities.php (Slovak)

* New translations settings.php (Slovak)

* New translations activities.php (Slovak)

* New translations settings.php (French)

* New translations settings.php (Russian)

* New translations settings.php (German)

* New translations settings.php (Polish)

* New translations validation.php (Polish)

* New translations auth.php (Vietnamese)

* New translations auth.php (Vietnamese)

* New translations activities.php (Vietnamese)

* New translations common.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Italian)

* New translations auth.php (Italian)

* New translations common.php (Italian)

* New translations common.php (German)

* New translations common.php (German Informal)

* New translations settings.php (German)

* New translations common.php (German)

* New translations common.php (German Informal)

* New translations errors.php (German)
2021-10-04 16:22:16 +01:00
Dan Brown
d3a9645161 Allowed page includes on custom home
For #2279
Old hold-over for when include content permissions were handled less
delicately.
2021-10-04 11:26:26 +01:00
Dan Brown
505d7e604e Applied StyleCI changes 2021-09-29 23:53:11 +01:00
Dan Brown
025442fcd9 Reviewed addition to db table prefix
Review of #2935

- Removed from .env files and added warnings for use if found in config
  file.
- Updated permission service to use whereColumn queries to auto-handle
  use of prefixes.
2021-09-29 18:41:11 +01:00
Dan Brown
0f66c8a0cc Merge branch 'floviolleau-db-prefixes' of https://github.com/floviolleau/BookStack into floviolleau-floviolleau-db-prefixes 2021-09-29 18:13:38 +01:00
Dan Brown
887a79f130 Reviewed adding IP recording to activity & audit log
Review of #2936

- Added testing to cover
- Added APP_PROXIES to .env.example.complete with details.
- Renamed migration to better align the name and to set the migration
  date to fit with production deploy order.
- Removed index from IP column in migration since an index does not yet
  provide any value.
- Updated table header text label.
- Prevented IP recording when in demo mode.
2021-09-26 17:18:12 +01:00
Dan Brown
8972f7b212 Merge branch 'log-ip-address' of https://github.com/johnroyer/BookStack into johnroyer-log-ip-address 2021-09-26 16:17:28 +01:00
Dan Brown
c100560bd9 Applied style ci changes again 2021-09-26 15:49:25 +01:00
Dan Brown
05d99a312d Applied styleci changes 2021-09-26 15:48:22 +01:00
Dan Brown
5c7eb0df57 Caught old string helper function usage
Found by Laravel Shift Workbench
2021-09-26 15:41:11 +01:00
Dan Brown
c32b315cd7 Standardised facade usage to use via their FQCN
Done via Laravel Shift Workbench
2021-09-26 15:37:55 +01:00
Zero
c0da5616f3 Fix coding style 2021-09-23 11:07:13 +08:00
Zero
6418824139 Update translation file 2021-09-20 11:29:14 +08:00
Zero
b834f58e87 Add user IP into audit table 2021-09-20 11:29:14 +08:00
Zero
8efaeb068b Save user IP to audit log 2021-09-20 11:29:14 +08:00
Zero
5cf0c99e32 Add IP column 2021-09-20 11:29:14 +08:00
floviolleau
dbfa2d58ed Allow to use DB tables prefix 2021-09-19 14:33:54 +02:00
floviolleau
f8abad1e3b Allow to use DB tables prefix 2021-09-19 14:32:35 +02:00
floviolleau
1a8ae41263 Allow to use DB tables prefix 2021-09-19 14:31:18 +02:00
floviolleau
00af40ab14 Allow to use DB tables prefix 2021-09-19 14:28:57 +02:00
Dan Brown
ffdfdc7449 Fixed dodgy test helper signature causing tests to fail
Just needed some argument defaults to make them optional for existing
uses.
2021-09-18 21:29:42 +01:00
Dan Brown
ba075b46f9 Merge pull request #2928 from BookStackApp/browserkit_removal
Convert old BrowserKit tests
2021-09-18 21:28:16 +01:00
Dan Brown
c08c8d7aa3 Applied styleci style changes 2021-09-18 21:21:44 +01:00
Dan Brown
6454e24657 Removed browserkit testing from project
Converted last bits of the roles tests and removed dependancies.
Updated other PHP dependancies at the same time.
2021-09-18 21:20:38 +01:00
Dan Brown
d74255df5d Started updating RolesTest away from Browserkit 2021-09-18 00:33:03 +01:00
Dan Brown
a4d9bca9e1 Converted AuthTest away from BrowserKit
Moved some user managment tests out to more relevant classess along the
way.
Found some tweaks to make for email confirmation routing as part of
this.
2021-09-17 23:44:54 +01:00
Dan Brown
90c759e5ca Rewrote entity permissions tests to be non-browser-kit 2021-09-17 22:35:28 +01:00
Dan Brown
5d93dd258e Finished moving EntityTests out to new TestCase files 2021-09-17 21:29:16 +01:00
Dan Brown
de8cceb0f7 Moved more tests out of EntityTest 2021-09-15 22:18:37 +01:00
Dan Brown
8a7408bd31 Fixed social auth login audit log messages
Was logging the whole social account instance instead of just the
method.
Updated tests to cover.

Fixes #2930
2021-09-15 20:55:10 +01:00
Dan Brown
121a746d59 Moved/Updated old Activity tracking tests, started on entity tests
Started moving old EntityTests into more appropriate places within
non-browserkit-test classes. Still many more to do.
2021-09-13 23:26:39 +01:00
Dan Brown
badaf08e55 Removed browserkit from a couple of classess
Done a little reorganisation while there of misplaced tests.
Moved MarkdownTest to a new PageEditorTest to avoid confusion with
other markdown elements and to align with other page tests.
2021-09-13 22:54:21 +01:00
Dan Brown
8565187138 Added border to generated TOTP QR code
To fix QR code not being scannable when in dark mode due to
lack of border matching background of QR code.

Fixes #2925
2021-09-13 14:23:54 +01:00
Franke
07408ec112 Fixes for CodeStyle vol.2 2021-08-30 14:44:52 +02:00
Franke
234dd26d22 Fixes for CodeStyle 2021-08-30 14:43:35 +02:00
Franke
75749ef336 Fixed SAML logout for ADFS. 2021-08-30 14:35:11 +02:00
MatthieuParis
3c4415f3ff Typo. 2021-08-08 21:59:04 +02:00
MatthieuParis
c2e031ae3e Testing command suppressed. 2021-08-08 20:35:12 +02:00
MatthieuParis
537b1614c4 Display warnings when saving draft if another user is editing the page or if the page was updated since the current user has started editing the page. 2021-08-08 19:20:15 +02:00
Jasper Weyne
69a47319d5 Default OpenID display name set to standard value 2020-08-05 13:14:46 +02:00
Jasper Weyne
35c48b9416 Method descriptions 2020-08-05 00:18:43 +02:00
Jasper Weyne
f2d320825a Simplify refresh method 2020-08-04 22:09:53 +02:00
Jasper Weyne
23402ae812 Initial unit tests for OpenID 2020-08-04 21:30:17 +02:00
Jasper Weyne
6feaf25c90 Increase robustness of the refresh method 2020-08-04 21:29:11 +02:00
Jasper Weyne
46388a591b AccessToken empty array parameter on null 2020-07-09 18:29:44 +02:00
Jasper Weyne
75b4a05200 Add OpenIdService to OpenIdSessionGuard constructor call 2020-07-09 18:00:16 +02:00
Jasper Weyne
13d0260cc9 Configurable OpenID Connect services 2020-07-09 16:27:45 +02:00
Jasper Weyne
97cde9c56a Generalize refresh failure handling 2020-07-08 17:02:52 +02:00
Jasper Weyne
5df7db5105 Ignore ID token expiry if unavailable 2020-07-07 02:51:33 +02:00
Jasper Weyne
10c890947f Token expiration and refreshing using the refresh_token flow 2020-07-07 02:26:00 +02:00
Jasper Weyne
25144a13c7 Deduplicated getOrRegisterUser method 2020-07-06 18:14:43 +02:00
Jasper Weyne
07a6d7655f First basic OpenID Connect implementation 2020-07-01 23:27:50 +02:00
673 changed files with 23538 additions and 9174 deletions

View File

@@ -41,4 +41,4 @@ MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_ENCRYPTION=null

View File

@@ -42,6 +42,14 @@ APP_TIMEZONE=UTC
# overrides can be made. Defaults to disabled.
APP_THEME=false
# 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.
# Set to an IP address, or multiple comma seperated IP addresses.
# Can alternatively be set to "*" to trust all proxy addresses.
APP_PROXIES=null
# Database details
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
DB_HOST=localhost
@@ -92,8 +100,7 @@ MEMCACHED_SERVERS=127.0.0.1:11211:100
REDIS_SERVERS=127.0.0.1:6379:0
# Queue driver to use
# Queue not really currently used but may be configurable in the future.
# Would advise not to change this for now.
# Can be 'sync', 'database' or 'redis'
QUEUE_CONNECTION=sync
# Storage system to use
@@ -126,7 +133,7 @@ STORAGE_S3_ENDPOINT=https://my-custom-s3-compatible.service.com:8001
STORAGE_URL=false
# Authentication method to use
# Can be 'standard', 'ldap' or 'saml2'
# Can be 'standard', 'ldap', 'saml2' or 'oidc'
AUTH_METHOD=standard
# Social authentication configuration
@@ -224,6 +231,8 @@ SAML2_ONELOGIN_OVERRIDES=null
SAML2_DUMP_USER_DETAILS=false
SAML2_AUTOLOAD_METADATA=false
SAML2_IDP_AUTHNCONTEXT=true
SAML2_SP_x509=null
SAML2_SP_x509_KEY=null
# SAML group sync configuration
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
@@ -231,6 +240,19 @@ SAML2_USER_TO_GROUPS=false
SAML2_GROUP_ATTRIBUTE=group
SAML2_REMOVE_FROM_GROUPS=false
# OpenID Connect authentication configuration
# Refer to https://www.bookstackapp.com/docs/admin/oidc-auth/
OIDC_NAME=SSO
OIDC_DISPLAY_NAME_CLAIMS=name
OIDC_CLIENT_ID=null
OIDC_CLIENT_SECRET=null
OIDC_ISSUER=null
OIDC_ISSUER_DISCOVER=false
OIDC_PUBLIC_KEY=null
OIDC_AUTH_ENDPOINT=null
OIDC_TOKEN_ENDPOINT=null
OIDC_DUMP_USER_DETAILS=false
# Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option
DISABLE_EXTERNAL_SERVICES=false
@@ -271,6 +293,15 @@ REVISION_LIMIT=50
# Set to -1 for unlimited recycle bin lifetime.
RECYCLE_BIN_LIFETIME=30
# File Upload Limit
# 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

View File

@@ -1,17 +0,0 @@
---
name: New API Endpoint or Feature
about: Request a new endpoint or API feature be added
labels: ":nut_and_bolt: API Request"
---
#### API Endpoint or Feature
Clearly describe what you'd like to have added to the API.
#### Use-Case
Explain the use-case that you're working-on that requires the above request.
#### Additional Context
If required, add any other context about the feature request here.

26
.github/ISSUE_TEMPLATE/api_request.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
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
id: feature
attributes:
label: API Endpoint or Feature
description: Clearly describe what you'd like to have added to the API.
validations:
required: true
- type: textarea
id: usecase
attributes:
label: Use-Case
description: Explain the use-case that you're working-on that requires the above request.
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the feature request here.
validations:
required: false

View File

@@ -1,29 +0,0 @@
---
name: Bug Report
about: Create a report to help us improve
---
**Describe the bug**
A clear and concise description of what the bug is.
**Steps To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Your Configuration (please complete the following information):**
- Exact BookStack Version (Found in settings):
- PHP Version:
- Hosting Method (Nginx/Apache/Docker):
**Additional context**
Add any other context about the problem here.

62
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,62 @@
name: Bug Report
description: Create a report to help us improve or fix things
title: "[Bug Report]: "
labels: [":bug: Bug"]
body:
- type: textarea
id: description
attributes:
label: Describe the Bug
description: Provide a clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Detail the steps that would replicate this issue
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behaviour
description: Provide clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: context
attributes:
label: Screenshots or Additional Context
description: Provide any additional context and screenshots here to help us solve this issue
validations:
required: false
- type: input
id: bsversion
attributes:
label: Exact BookStack Version
description: This can be found in the settings view of BookStack. Please provide an exact version.
placeholder: (eg. v21.08.5)
validations:
required: true
- type: input
id: phpversion
attributes:
label: PHP Version
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that relevant to the issue.
placeholder: (eg. 7.4)
validations:
required: false
- type: textarea
id: hosting
attributes:
label: Hosting Environment
description: Describe your hosting environment as much as possible including any proxies used (If applicable).
placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
validations:
required: true

View File

@@ -1,14 +0,0 @@
---
name: Feature Request
about: Suggest an idea for this project
---
**Describe the feature you'd like**
A clear description of the feature you'd like implemented in BookStack.
**Describe the benefits this feature would bring to BookStack users**
Explain the measurable benefits this feature would achieve.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,26 @@
name: Feature Request
description: Request a new language to be added to CrowdIn for you to translate
title: "[Feature Request]: "
labels: [":hammer: Feature Request"]
body:
- type: textarea
id: description
attributes:
label: Describe the feature you'd like
description: Provide a clear description of the feature you'd like implemented in BookStack
validations:
required: true
- 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
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false

View File

@@ -1,13 +0,0 @@
---
name: Language Request
about: Request a new language to be added to Crowdin for you to translate
---
### Language To Add
_Specify here the language you want to add._
----
_This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack). Please don't use this template to request a new language that you are not prepared to provide translations for._

View File

@@ -0,0 +1,32 @@
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
body:
- type: markdown
attributes:
value: |
Thanks for offering to help start a new translation for BookStack!
- type: input
id: language
attributes:
label: Language to Add
description: What language (and region if applicable) are you offering to help add to BookStack?
validations:
required: true
- type: checkboxes
id: confirm
attributes:
label: Confirmation of Intent
description: |
This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack).
Please don't use this template to request a new language that you are not prepared to provide translations for.
options:
- label: I confirm I'm offering to help translate for this new language via CrowdIn.
required: true
- type: markdown
attributes:
value: |
*__Note: New languages are added at specific points of the development process so it may be a small while before the requested language is added for translation.__*

View File

@@ -0,0 +1,63 @@
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
id: useddocs
attributes:
label: Attempted Debugging
description: |
I have read the [BookStack debugging](https://www.bookstackapp.com/docs/admin/debugging/) page and seeked resolution or more
detail for the issue.
options:
- label: I have read the debugging page
required: true
- type: checkboxes
id: searchissue
attributes:
label: Searched GitHub Issues
description: |
I have searched for the issue and potential resolutions within the [project's GitHub issue list](https://github.com/BookStackApp/BookStack/issues)
options:
- label: I have searched GitHub for the issue.
required: true
- type: textarea
id: scenario
attributes:
label: Describe the Scenario
description: Detail the problem that you're having or what you need support with.
validations:
required: true
- type: input
id: bsversion
attributes:
label: Exact BookStack Version
description: This can be found in the settings view of BookStack. Please provide an exact version.
placeholder: (eg. v21.08.5)
validations:
required: true
- type: textarea
id: logs
attributes:
label: Log Content
description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below.
placeholder: Be sure to remove any confidential details in your logs
validations:
required: false
- type: input
id: phpversion
attributes:
label: PHP Version
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that most relevant to the issue.
placeholder: (eg. 7.4)
validations:
required: false
- type: textarea
id: hosting
attributes:
label: Hosting Environment
description: Describe your hosting environment as much as possible including any proxies used (If applicable).
placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
validations:
required: true

32
.github/SECURITY.md vendored Normal file
View File

@@ -0,0 +1,32 @@
# Security Policy
## Supported Versions
Only the [latest version](https://github.com/BookStackApp/BookStack/releases) of BookStack is supported.
We generally don't support older versions of BookStack due to maintenance effort and
since we aim to provide a fairly stable upgrade path for new versions.
## Security Notifications
If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates).
## Reporting a Vulnerability
If you've found an issue that likely has no impact to existing users (For example, in a development-only branch)
feel free to raise it via a standard GitHub bug report issue.
If the issue could have a security impact to BookStack instances, please use one of the below
methods to report the vulnerability:
- Directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown).
- You will need to login to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
- Alternatively you can send a DM via Twitter to [@ssddanbrown](https://twitter.com/ssddanbrown).
- [Disclose via huntr.dev](https://huntr.dev/bounties/disclose)
- Bounties may be available to you through this platform.
- Be sure to use `https://github.com/BookStackApp/BookStack` as the repository URL.
Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability
can often take a little time due to the amount of preparation required, to ensure the vulnerability has
been covered, and to create the content required to adequately notify the user-base.
Thank you for keeping BookStack instances safe!

View File

@@ -126,7 +126,7 @@ Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
tatsuya.info :: Japanese
fadiapp :: Arabic
Jakub Bouček (jakubboucek) :: Czech
Marco (cdrfun) :: German
Marco (cdrfun) :: German; German Informal
10935336 :: Chinese Simplified
孟繁阳 (FanyangMeng) :: Chinese Simplified
Andrej Močan (andrejm) :: Slovenian
@@ -184,5 +184,34 @@ Frost-ZX :: Chinese Simplified
Kuzma Simonov (ovmach) :: Russian
Vojtěch Krystek (acantophis) :: Czech
Michał Lipok (mLipok) :: Polish
Nicolas Pawlak (Mikolajek) :: French
Nicolas Pawlak (Mikolajek) :: French; Polish; German
Thomas Hansen (thomasdk81) :: Danish
Hl2run :: Slovak
Ngo Tri Hoai (trihoai) :: Vietnamese
Atalonica :: Catalan
慕容潭谈 (591442386) :: Chinese Simplified
Radim Pesek (ramess18) :: Czech
anastasiia.motylko :: Ukrainian
Indrek Haav (IndrekHaav) :: Estonian
na3shkw :: Japanese
Giancarlo Di Massa (digitall-it) :: Italian
M Nafis Al Mukhdi (mnafisalmukhdi1) :: Indonesian
sulfo :: Danish
Raukze :: German
zygimantus :: Lithuanian
marinkaberg :: Russian
Vitaliy (gviabcua) :: Ukrainian
mannycarreiro :: Portuguese
Thiago Rafael Pereira de Carvalho (thiago.rafael) :: Portuguese, Brazilian
Ken Roger Bolgnes (kenbo124) :: Norwegian Bokmal
Nguyen Hung Phuong (hnwolf) :: Vietnamese
Umut ERGENE (umutergene67) :: Turkish
Tomáš Batelka (Vofy) :: Czech
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

41
.github/workflows/phpstan.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: phpstan
on:
push:
branches-ignore:
- l10n_development
pull_request:
branches-ignore:
- l10n_development
jobs:
build:
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.3']
steps:
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v1
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
- name: Install composer dependencies
run: composer install --prefer-dist --no-interaction --ansi
- name: Run PHPStan
run: php${{ matrix.php }} ./vendor/bin/phpstan analyse --memory-limit=2G

View File

@@ -3,22 +3,22 @@ name: phpunit
on:
push:
branches-ignore:
- l10n_master
- l10n_development
pull_request:
branches-ignore:
- l10n_master
- l10n_development
jobs:
build:
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.3', '7.4', '8.0']
php: ['7.3', '7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@b7d1d9c9a92d8d8463ce36d7f60da34d461724f8
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap
@@ -36,7 +36,7 @@ jobs:
- name: Start Database
run: |
sudo /etc/init.d/mysql start
sudo systemctl start mysql
- name: Setup Database
run: |
@@ -45,7 +45,7 @@ jobs:
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
- name: Install composer dependencies & Test
- name: Install composer dependencies
run: composer install --prefer-dist --no-interaction --ansi
- name: Migrate and seed the database

View File

@@ -3,22 +3,22 @@ name: test-migrations
on:
push:
branches-ignore:
- l10n_master
- l10n_development
pull_request:
branches-ignore:
- l10n_master
- l10n_development
jobs:
build:
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.3', '7.4', '8.0']
php: ['7.3', '7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@b7d1d9c9a92d8d8463ce36d7f60da34d461724f8
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap
@@ -36,7 +36,7 @@ jobs:
- name: Start MySQL
run: |
sudo /etc/init.d/mysql start
sudo systemctl start mysql
- name: Create database & user
run: |

3
.gitignore vendored
View File

@@ -23,4 +23,5 @@ nbproject
.settings/
webpack-stats.json
.phpunit.result.cache
.DS_Store
.DS_Store
phpstan.neon

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2020 Dan Brown and the BookStack Project contributors
Copyright (c) 2015-present, Dan Brown and the BookStack Project contributors
https://github.com/BookStackApp/BookStack/graphs/contributors
Permission is hereby granted, free of charge, to any person obtaining a copy

View File

@@ -61,7 +61,7 @@ class Activity extends Model
/**
* Checks if another Activity matches the general information of another.
*/
public function isSimilarTo(Activity $activityB): bool
public function isSimilarTo(self $activityB): bool
{
return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
}

View File

@@ -0,0 +1,115 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
class ActivityLogger
{
protected $permissionService;
public function __construct(PermissionService $permissionService)
{
$this->permissionService = $permissionService;
}
/**
* Add a generic activity event to the database.
*
* @param string|Loggable $detail
*/
public function add(string $type, $detail = '')
{
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
$activity = $this->newActivityForUser($type);
$activity->detail = $detailToStore;
if ($detail instanceof Entity) {
$activity->entity_id = $detail->id;
$activity->entity_type = $detail->getMorphClass();
}
$activity->save();
$this->setNotification($type);
$this->dispatchWebhooks($type, $detail);
}
/**
* Get a new activity instance for the current user.
*/
protected function newActivityForUser(string $type): Activity
{
$ip = request()->ip() ?? '';
return (new Activity())->forceFill([
'type' => strtolower($type),
'user_id' => user()->id,
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
]);
}
/**
* Removes the entity attachment from each of its activities
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
*/
public function removeEntity(Entity $entity)
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
]);
}
/**
* Flashes a notification message to the session if an appropriate message is available.
*/
protected function setNotification(string $type): void
{
$notificationTextKey = 'activities.' . $type . '_notification';
if (trans()->has($notificationTextKey)) {
$message = trans($notificationTextKey);
session()->flash('success', $message);
}
}
/**
* @param string|Loggable $detail
*/
protected function dispatchWebhooks(string $type, $detail): void
{
$webhooks = Webhook::query()
->whereHas('trackedEvents', function (Builder $query) use ($type) {
$query->where('event', '=', $type)
->orWhere('event', '=', 'all');
})
->where('active', '=', true)
->get();
foreach ($webhooks as $webhook) {
dispatch(new DispatchWebhookJob($webhook, $type, $detail));
}
}
/**
* Log out a failed login attempt, Providing the given username
* as part of the message if the '%u' string is used.
*/
public function logFailedLogin(string $username)
{
$message = config('logging.failed_login.message');
if (!$message) {
return;
}
$message = str_replace('%u', $username, $message);
$channel = config('logging.failed_login.channel');
Log::channel($channel)->warning($message);
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
class ActivityQueries
{
protected $permissionService;
public function __construct(PermissionService $permissionService)
{
$this->permissionService = $permissionService;
}
/**
* Gets the latest activity.
*/
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['user', 'entity'])
->skip($count * $page)
->take($count)
->get();
return $this->filterSimilar($activityList);
}
/**
* Gets the latest activity for an entity, Filtering out similar
* items to prevent a message activity list.
*/
public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
{
/** @var array<string, int[]> $queryIds */
$queryIds = [$entity->getMorphClass() => [$entity->id]];
if ($entity instanceof Book) {
$queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->scopes('visible')->pluck('id');
}
if ($entity instanceof Book || $entity instanceof Chapter) {
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
}
$query = Activity::query();
$query->where(function (Builder $query) use ($queryIds) {
foreach ($queryIds as $morphClass => $idArr) {
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
$innerQuery->where('entity_type', '=', $morphClass)
->whereIn('entity_id', $idArr);
});
}
});
$activity = $query->orderBy('created_at', 'desc')
->with(['entity' => function (Relation $query) {
$query->withTrashed();
}, 'user.avatar'])
->skip($count * ($page - 1))
->take($count)
->get();
return $this->filterSimilar($activity);
}
/**
* Get the latest activity for a user, Filtering out similar items.
*/
public function userActivity(User $user, int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)
->take($count)
->get();
return $this->filterSimilar($activityList);
}
/**
* Filters out similar activity.
*
* @param Activity[] $activities
*/
protected function filterSimilar(iterable $activities): array
{
$newActivity = [];
$previousItem = null;
foreach ($activities as $activityItem) {
if (!$previousItem || !$activityItem->isSimilarTo($previousItem)) {
$newActivity[] = $activityItem;
}
$previousItem = $activityItem;
}
return $newActivity;
}
}

View File

@@ -1,197 +0,0 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Log;
class ActivityService
{
protected $activity;
protected $permissionService;
public function __construct(Activity $activity, PermissionService $permissionService)
{
$this->activity = $activity;
$this->permissionService = $permissionService;
}
/**
* Add activity data to database for an entity.
*/
public function addForEntity(Entity $entity, string $type)
{
$activity = $this->newActivityForUser($type);
$entity->activity()->save($activity);
$this->setNotification($type);
}
/**
* Add a generic activity event to the database.
*
* @param string|Loggable $detail
*/
public function add(string $type, $detail = '')
{
if ($detail instanceof Loggable) {
$detail = $detail->logDescriptor();
}
$activity = $this->newActivityForUser($type);
$activity->detail = $detail;
$activity->save();
$this->setNotification($type);
}
/**
* Get a new activity instance for the current user.
*/
protected function newActivityForUser(string $type): Activity
{
return $this->activity->newInstance()->forceFill([
'type' => strtolower($type),
'user_id' => user()->id,
]);
}
/**
* Removes the entity attachment from each of its activities
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
*/
public function removeEntity(Entity $entity)
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
]);
}
/**
* Gets the latest activity.
*/
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['user', 'entity'])
->skip($count * $page)
->take($count)
->get();
return $this->filterSimilar($activityList);
}
/**
* Gets the latest activity for an entity, Filtering out similar
* items to prevent a message activity list.
*/
public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
{
/** @var [string => int[]] $queryIds */
$queryIds = [$entity->getMorphClass() => [$entity->id]];
if ($entity->isA('book')) {
$queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->visible()->pluck('id');
}
if ($entity->isA('book') || $entity->isA('chapter')) {
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->visible()->pluck('id');
}
$query = $this->activity->newQuery();
$query->where(function (Builder $query) use ($queryIds) {
foreach ($queryIds as $morphClass => $idArr) {
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
$innerQuery->where('entity_type', '=', $morphClass)
->whereIn('entity_id', $idArr);
});
}
});
$activity = $query->orderBy('created_at', 'desc')
->with(['entity' => function (Relation $query) {
$query->withTrashed();
}, 'user.avatar'])
->skip($count * ($page - 1))
->take($count)
->get();
return $this->filterSimilar($activity);
}
/**
* Get latest activity for a user, Filtering out similar items.
*/
public function userActivity(User $user, int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)
->take($count)
->get();
return $this->filterSimilar($activityList);
}
/**
* Filters out similar activity.
*
* @param Activity[] $activities
*
* @return array
*/
protected function filterSimilar(iterable $activities): array
{
$newActivity = [];
$previousItem = null;
foreach ($activities as $activityItem) {
if (!$previousItem || !$activityItem->isSimilarTo($previousItem)) {
$newActivity[] = $activityItem;
}
$previousItem = $activityItem;
}
return $newActivity;
}
/**
* Flashes a notification message to the session if an appropriate message is available.
*/
protected function setNotification(string $type)
{
$notificationTextKey = 'activities.' . $type . '_notification';
if (trans()->has($notificationTextKey)) {
$message = trans($notificationTextKey);
session()->flash('success', $message);
}
}
/**
* Log out a failed login attempt, Providing the given username
* as part of the message if the '%u' string is used.
*/
public function logFailedLogin(string $username)
{
$message = config('logging.failed_login.message');
if (!$message) {
return;
}
$message = str_replace('%u', $username, $message);
$channel = config('logging.failed_login.channel');
Log::channel($channel)->warning($message);
}
}

View File

@@ -53,4 +53,16 @@ class ActivityType
const MFA_SETUP_METHOD = 'mfa_setup_method';
const MFA_REMOVE_METHOD = 'mfa_remove_method';
const WEBHOOK_CREATE = 'webhook_create';
const WEBHOOK_UPDATE = 'webhook_update';
const WEBHOOK_DELETE = 'webhook_delete';
/**
* Get all the possible values.
*/
public static function all(): array
{
return (new \ReflectionClass(static::class))->getConstants();
}
}

View File

@@ -4,16 +4,19 @@ namespace BookStack\Actions;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property string text
* @property string html
* @property int|null parent_id
* @property int local_id
* @property int $id
* @property string $text
* @property string $html
* @property int|null $parent_id
* @property int $local_id
*/
class Comment extends Model
{
use HasFactory;
use HasCreatorAndUpdater;
protected $fillable = ['text', 'parent_id'];

View File

@@ -45,7 +45,7 @@ class CommentRepo
$comment->parent_id = $parent_id;
$entity->comments()->save($comment);
ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
return $comment;
}
@@ -66,13 +66,13 @@ class CommentRepo
/**
* Delete a comment from the system.
*/
public function delete(Comment $comment)
public function delete(Comment $comment): void
{
$comment->delete();
}
/**
* Convert the given comment markdown text to HTML.
* Convert the given comment Markdown to HTML.
*/
public function commentToHtml(string $commentText): string
{
@@ -90,8 +90,9 @@ class CommentRepo
*/
protected function getNextLocalId(Entity $entity): int
{
$comments = $entity->comments(false)->orderBy('local_id', 'desc')->first();
/** @var Comment $comment */
$comment = $entity->comments(false)->orderBy('local_id', 'desc')->first();
return ($comments->local_id ?? 0) + 1;
return ($comment->local_id ?? 0) + 1;
}
}

View File

@@ -0,0 +1,132 @@
<?php
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;
class DispatchWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* @var Webhook
*/
protected $webhook;
/**
* @var string
*/
protected $event;
/**
* @var string|Loggable
*/
protected $detail;
/**
* @var User
*/
protected $initiator;
/**
* @var int
*/
protected $initiatedTime;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Webhook $webhook, string $event, $detail)
{
$this->webhook = $webhook;
$this->event = $event;
$this->detail = $detail;
$this->initiator = user();
$this->initiatedTime = time();
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail);
$webhookData = $themeResponse ?? $this->buildWebhookData();
$lastError = null;
try {
$response = Http::asJson()
->withOptions(['allow_redirects' => ['strict' => true]])
->timeout($this->webhook->timeout)
->post($this->webhook->endpoint, $webhookData);
} catch (\Exception $exception) {
$lastError = $exception->getMessage();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
}
if (isset($response) && $response->failed()) {
$lastError = "Response status from endpoint was {$response->status()}";
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
}
$this->webhook->last_called_at = now();
if ($lastError) {
$this->webhook->last_errored_at = now();
$this->webhook->last_error = $lastError;
}
$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

@@ -3,10 +3,19 @@
namespace BookStack\Actions;
use BookStack\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property string $name
* @property string $value
* @property int $order
*/
class Tag extends Model
{
use HasFactory;
protected $fillable = ['name', 'value', 'order'];
protected $hidden = ['id', 'entity_id', 'entity_type', 'created_at', 'updated_at'];

View File

@@ -4,30 +4,63 @@ namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
use DB;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class TagRepo
{
protected $tag;
protected $permissionService;
/**
* TagRepo constructor.
*/
public function __construct(Tag $tag, PermissionService $ps)
public function __construct(PermissionService $ps)
{
$this->tag = $tag;
$this->permissionService = $ps;
}
/**
* Start a query against all tags in the system.
*/
public function queryWithTotals(string $searchTerm, string $nameFilter): Builder
{
$query = Tag::query()
->select([
'name',
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
DB::raw('COUNT(id) as usages'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Page\', 1, 0)) as page_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Chapter\', 1, 0)) as chapter_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\BookShelf\', 1, 0)) as shelf_count'),
])
->orderBy($nameFilter ? 'value' : 'name');
if ($nameFilter) {
$query->where('name', '=', $nameFilter);
$query->groupBy('value');
} elseif ($searchTerm) {
$query->groupBy('name', 'value');
} else {
$query->groupBy('name');
}
if ($searchTerm) {
$query->where(function (Builder $query) use ($searchTerm) {
$query->where('name', 'like', '%' . $searchTerm . '%')
->orWhere('value', 'like', '%' . $searchTerm . '%');
});
}
return $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
}
/**
* Get tag name suggestions from scanning existing tag names.
* If no search term is given the 50 most popular tag names are provided.
*/
public function getNameSuggestions(?string $searchTerm): Collection
{
$query = $this->tag->newQuery()
$query = Tag::query()
->select('*', DB::raw('count(*) as count'))
->groupBy('name');
@@ -49,7 +82,7 @@ class TagRepo
*/
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
{
$query = $this->tag->newQuery()
$query = Tag::query()
->select('*', DB::raw('count(*) as count'))
->groupBy('value');
@@ -90,9 +123,9 @@ class TagRepo
*/
protected function newInstanceFromInput(array $input): Tag
{
$name = trim($input['name']);
$value = isset($input['value']) ? trim($input['value']) : '';
return $this->tag->newInstance(['name' => $name, 'value' => $value]);
return new Tag([
'name' => trim($input['name']),
'value' => trim($input['value'] ?? ''),
]);
}
}

85
app/Actions/Webhook.php Normal file
View File

@@ -0,0 +1,85 @@
<?php
namespace BookStack\Actions;
use BookStack\Interfaces\Loggable;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property string $name
* @property string $endpoint
* @property Collection $trackedEvents
* @property bool $active
* @property int $timeout
* @property string $last_error
* @property Carbon $last_called_at
* @property Carbon $last_errored_at
*/
class Webhook extends Model implements Loggable
{
protected $fillable = ['name', 'endpoint', 'timeout'];
use HasFactory;
protected $casts = [
'last_called_at' => 'datetime',
'last_errored_at' => 'datetime',
];
/**
* Define the tracked event relation a webhook.
*/
public function trackedEvents(): HasMany
{
return $this->hasMany(WebhookTrackedEvent::class);
}
/**
* Update the tracked events for a webhook from the given list of event types.
*/
public function updateTrackedEvents(array $events): void
{
$this->trackedEvents()->delete();
$eventsToStore = array_intersect($events, array_values(ActivityType::all()));
if (in_array('all', $events)) {
$eventsToStore = ['all'];
}
$trackedEvents = [];
foreach ($eventsToStore as $event) {
$trackedEvents[] = new WebhookTrackedEvent(['event' => $event]);
}
$this->trackedEvents()->saveMany($trackedEvents);
}
/**
* Check if this webhook tracks the given event.
*/
public function tracksEvent(string $event): bool
{
return $this->trackedEvents->pluck('event')->contains($event);
}
/**
* Get a URL for this webhook within the settings interface.
*/
public function getUrl(string $path = ''): string
{
return url('/settings/webhooks/' . $this->id . '/' . ltrim($path, '/'));
}
/**
* Get the string descriptor for this item.
*/
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}";
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace BookStack\Actions;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $webhook_id
* @property string $event
*/
class WebhookTrackedEvent extends Model
{
protected $fillable = ['event'];
use HasFactory;
}

View File

@@ -28,7 +28,7 @@ class ApiDocsGenerator
if (Cache::has($cacheKey) && config('app.env') === 'production') {
$docs = Cache::get($cacheKey);
} else {
$docs = (new static())->generate();
$docs = (new ApiDocsGenerator())->generate();
Cache::put($cacheKey, $docs, 60 * 24);
}
@@ -55,10 +55,16 @@ class ApiDocsGenerator
{
return $routes->map(function (array $route) {
$exampleTypes = ['request', 'response'];
$fileTypes = ['json', 'http'];
foreach ($exampleTypes as $exampleType) {
$exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}.json");
$exampleContent = file_exists($exampleFile) ? file_get_contents($exampleFile) : null;
$route["example_{$exampleType}"] = $exampleContent;
foreach ($fileTypes as $fileType) {
$exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}." . $fileType);
if (file_exists($exampleFile)) {
$route["example_{$exampleType}"] = file_get_contents($exampleFile);
continue 2;
}
}
$route["example_{$exampleType}"] = null;
}
return $route;
@@ -95,17 +101,14 @@ class ApiDocsGenerator
}
$rules = $class->getValdationRules()[$methodName] ?? [];
foreach ($rules as $param => $ruleString) {
$rules[$param] = explode('|', $ruleString);
}
return count($rules) > 0 ? $rules : null;
return empty($rules) ? null : $rules;
}
/**
* Parse out the description text from a class method comment.
*/
protected function parseDescriptionFromMethodComment(string $comment)
protected function parseDescriptionFromMethodComment(string $comment): string
{
$matches = [];
preg_match_all('/^\s*?\*\s((?![@\s]).*?)$/m', $comment, $matches);

View File

@@ -43,7 +43,7 @@ class ApiToken extends Model implements Loggable
}
/**
* @inheritdoc
* {@inheritdoc}
*/
public function logDescriptor(): string
{

View File

@@ -42,7 +42,7 @@ class ApiTokenGuard implements Guard
}
/**
* @inheritDoc
* {@inheritdoc}
*/
public function user()
{
@@ -152,7 +152,7 @@ class ApiTokenGuard implements Guard
}
/**
* @inheritDoc
* {@inheritdoc}
*/
public function validate(array $credentials = [])
{

View File

@@ -4,6 +4,7 @@ namespace BookStack\Auth\Access;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Database\Eloquent\Model;
class ExternalBaseUserProvider implements UserProvider
{
@@ -16,8 +17,6 @@ class ExternalBaseUserProvider implements UserProvider
/**
* LdapUserProvider constructor.
*
* @param $model
*/
public function __construct(string $model)
{
@@ -27,7 +26,7 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Create a new instance of the model.
*
* @return \Illuminate\Database\Eloquent\Model
* @return Model
*/
public function createModel()
{
@@ -41,7 +40,7 @@ class ExternalBaseUserProvider implements UserProvider
*
* @param mixed $identifier
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
* @return Authenticatable|null
*/
public function retrieveById($identifier)
{
@@ -54,7 +53,7 @@ class ExternalBaseUserProvider implements UserProvider
* @param mixed $identifier
* @param string $token
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
* @return Authenticatable|null
*/
public function retrieveByToken($identifier, $token)
{
@@ -64,8 +63,8 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Update the "remember me" token for the given user in storage.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param string $token
* @param Authenticatable $user
* @param string $token
*
* @return void
*/
@@ -79,7 +78,7 @@ class ExternalBaseUserProvider implements UserProvider
*
* @param array $credentials
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
* @return Authenticatable|null
*/
public function retrieveByCredentials(array $credentials)
{
@@ -94,8 +93,8 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Validate a user against the given credentials.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param array $credentials
* @param Authenticatable $user
* @param array $credentials
*
* @return bool
*/

View File

@@ -6,7 +6,7 @@ use BookStack\Auth\Role;
use BookStack\Auth\User;
use Illuminate\Support\Collection;
class ExternalAuthService
class GroupSyncService
{
/**
* Check a role against an array of group names to see if it matches.
@@ -60,13 +60,13 @@ class ExternalAuthService
/**
* Sync the groups to the user roles for the current user.
*/
public function syncWithGroups(User $user, array $userGroups): void
public function syncUserWithFoundGroups(User $user, array $userGroups, bool $detachExisting): void
{
// Get the ids for the roles from the names
$groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups);
// Sync groups
if ($this->config['remove_from_groups']) {
if ($detachExisting) {
$user->roles()->sync($groupsAsRoles);
$user->attachDefaultRole();
} else {

View File

@@ -10,7 +10,7 @@ namespace BookStack\Auth\Access\Guards;
* via the Saml2 controller & Saml2Service. This class provides a safer, thin
* version of SessionGuard.
*/
class Saml2SessionGuard extends ExternalBaseSessionGuard
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
{
/**
* Validate a user's credentials.

View File

@@ -94,7 +94,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
}
// Attach avatar if non-existent
if (is_null($user->avatar)) {
if (!$user->avatar()->exists()) {
$this->ldapService->saveAndAttachAvatar($user, $userDetails);
}

View File

@@ -10,14 +10,11 @@ namespace BookStack\Auth\Access;
class Ldap
{
/**
* Connect to a LDAP server.
*
* @param string $hostName
* @param int $port
* Connect to an LDAP server.
*
* @return resource
*/
public function connect($hostName, $port)
public function connect(string $hostName, int $port)
{
return ldap_connect($hostName, $port);
}
@@ -26,12 +23,9 @@ class Ldap
* Set the value of a LDAP option for the given connection.
*
* @param resource $ldapConnection
* @param int $option
* @param mixed $value
*
* @return bool
*/
public function setOption($ldapConnection, $option, $value)
public function setOption($ldapConnection, int $option, $value): bool
{
return ldap_set_option($ldapConnection, $option, $value);
}
@@ -47,12 +41,9 @@ class Ldap
/**
* Set the version number for the given ldap connection.
*
* @param $ldapConnection
* @param $version
*
* @return bool
* @param resource $ldapConnection
*/
public function setVersion($ldapConnection, $version)
public function setVersion($ldapConnection, int $version): bool
{
return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
}

View File

@@ -13,9 +13,10 @@ use Illuminate\Support\Facades\Log;
* Class LdapService
* Handles any app-specific LDAP tasks.
*/
class LdapService extends ExternalAuthService
class LdapService
{
protected $ldap;
protected $groupSyncService;
protected $ldapConnection;
protected $userAvatars;
protected $config;
@@ -24,20 +25,19 @@ class LdapService extends ExternalAuthService
/**
* LdapService constructor.
*/
public function __construct(Ldap $ldap, UserAvatars $userAvatars)
public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
{
$this->ldap = $ldap;
$this->userAvatars = $userAvatars;
$this->groupSyncService = $groupSyncService;
$this->config = config('services.ldap');
$this->enabled = config('auth.method') === 'ldap';
}
/**
* Check if groups should be synced.
*
* @return bool
*/
public function shouldSyncGroups()
public function shouldSyncGroups(): bool
{
return $this->enabled && $this->config['user_to_groups'] !== false;
}
@@ -165,7 +165,7 @@ class LdapService extends ExternalAuthService
* Bind the system user to the LDAP connection using the given credentials
* otherwise anonymous access is attempted.
*
* @param $connection
* @param resource $connection
*
* @throws LdapException
*/
@@ -285,9 +285,8 @@ class LdapService extends ExternalAuthService
}
$userGroups = $this->groupFilter($user);
$userGroups = $this->getGroupsRecursive($userGroups, []);
return $userGroups;
return $this->getGroupsRecursive($userGroups, []);
}
/**
@@ -374,7 +373,7 @@ class LdapService extends ExternalAuthService
public function syncGroups(User $user, string $username)
{
$userLdapGroups = $this->getUserGroups($username);
$this->syncWithGroups($user, $userLdapGroups);
$this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);
}
/**

View File

@@ -47,7 +47,7 @@ class LoginService
// Authenticate on all session guards if a likely admin
if ($user->can('users-manage') && $user->can('user-roles-manage')) {
$guards = ['standard', 'ldap', 'saml2'];
$guards = ['standard', 'ldap', 'saml2', 'oidc'];
foreach ($guards as $guard) {
auth($guard)->login($user);
}

View File

@@ -8,6 +8,7 @@ use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\Fill;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use BookStack\Auth\User;
use PragmaRX\Google2FA\Google2FA;
use PragmaRX\Google2FA\Support\Constants;
@@ -36,11 +37,11 @@ class TotpService
/**
* Generate a TOTP URL from secret key.
*/
public function generateUrl(string $secret): string
public function generateUrl(string $secret, User $user): string
{
return $this->google2fa->getQRCodeUrl(
setting('app-name'),
user()->email,
$user->email,
$secret
);
}
@@ -54,7 +55,7 @@ class TotpService
return (new Writer(
new ImageRenderer(
new RendererStyle(192, 0, null, null, $color),
new RendererStyle(192, 4, null, null, $color),
new SvgImageBackEnd()
)
))->writeString($url);

View File

@@ -0,0 +1,53 @@
<?php
namespace BookStack\Auth\Access\Oidc;
use InvalidArgumentException;
use League\OAuth2\Client\Token\AccessToken;
class OidcAccessToken extends AccessToken
{
/**
* Constructs an access token.
*
* @param array $options An array of options returned by the service provider
* in the access token request. The `access_token` option is required.
*
* @throws InvalidArgumentException if `access_token` is not provided in `$options`.
*/
public function __construct(array $options = [])
{
parent::__construct($options);
$this->validate($options);
}
/**
* Validate this access token response for OIDC.
* As per https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
*/
private function validate(array $options): void
{
// access_token: REQUIRED. Access Token for the UserInfo Endpoint.
// Performed on the extended class
// token_type: REQUIRED. OAuth 2.0 Token Type value. The value MUST be Bearer, as specified in OAuth 2.0
// Bearer Token Usage [RFC6750], for Clients using this subset.
// Note that the token_type value is case-insensitive.
if (strtolower(($options['token_type'] ?? '')) !== 'bearer') {
throw new InvalidArgumentException('The response token type MUST be "Bearer"');
}
// id_token: REQUIRED. ID Token.
if (empty($options['id_token'])) {
throw new InvalidArgumentException('An "id_token" property must be provided');
}
}
/**
* Get the id token value from this access token response.
*/
public function getIdToken(): string
{
return $this->getValues()['id_token'];
}
}

View File

@@ -0,0 +1,238 @@
<?php
namespace BookStack\Auth\Access\Oidc;
class OidcIdToken
{
/**
* @var array
*/
protected $header;
/**
* @var array
*/
protected $payload;
/**
* @var string
*/
protected $signature;
/**
* @var array[]|string[]
*/
protected $keys;
/**
* @var string
*/
protected $issuer;
/**
* @var array
*/
protected $tokenParts = [];
public function __construct(string $token, string $issuer, array $keys)
{
$this->keys = $keys;
$this->issuer = $issuer;
$this->parse($token);
}
/**
* Parse the token content into its components.
*/
protected function parse(string $token): void
{
$this->tokenParts = explode('.', $token);
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
}
/**
* Parse a Base64-JSON encoded token part.
* Returns the data as a key-value array or empty array upon error.
*/
protected function parseEncodedTokenPart(string $part): array
{
$json = $this->base64UrlDecode($part) ?: '{}';
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Base64URL decode. Needs some character conversions to be compatible
* with PHP's default base64 handling.
*/
protected function base64UrlDecode(string $encoded): string
{
return base64_decode(strtr($encoded, '-_', '+/'));
}
/**
* Validate all possible parts of the id token.
*
* @throws OidcInvalidTokenException
*/
public function validate(string $clientId): bool
{
$this->validateTokenStructure();
$this->validateTokenSignature();
$this->validateTokenClaims($clientId);
return true;
}
/**
* Fetch a specific claim from this token.
* Returns null if it is null or does not exist.
*
* @return mixed|null
*/
public function getClaim(string $claim)
{
return $this->payload[$claim] ?? null;
}
/**
* Get all returned claims within the token.
*/
public function getAllClaims(): array
{
return $this->payload;
}
/**
* Validate the structure of the given token and ensure we have the required pieces.
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenStructure(): void
{
foreach (['header', 'payload'] as $prop) {
if (empty($this->$prop) || !is_array($this->$prop)) {
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
}
}
if (empty($this->signature) || !is_string($this->signature)) {
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
}
}
/**
* Validate the signature of the given token and ensure it validates against the provided key.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenSignature(): void
{
if ($this->header['alg'] !== 'RS256') {
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
}
$parsedKeys = array_map(function ($key) {
try {
return new OidcJwtSigningKey($key);
} catch (OidcInvalidKeyException $e) {
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
}
}, $this->keys);
$parsedKeys = array_filter($parsedKeys);
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
/** @var OidcJwtSigningKey $parsedKey */
foreach ($parsedKeys as $parsedKey) {
if ($parsedKey->verify($contentToSign, $this->signature)) {
return;
}
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
}
/**
* Validate the claims of the token.
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenClaims(string $clientId): void
{
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
// MUST exactly match the value of the iss (issuer) Claim.
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
}
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
// if the ID Token does not list the Client as a valid audience, or if it contains additional
// audiences not trusted by the Client.
if (empty($this->payload['aud'])) {
throw new OidcInvalidTokenException('Missing token audience value');
}
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
if (count($aud) !== 1) {
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
}
if ($aud[0] !== $clientId) {
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
}
// 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
// NOTE: Addressed by enforcing a count of 1 above.
// 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id
// is the Claim Value.
if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) {
throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id');
}
// 5. The current time MUST be before the time represented by the exp Claim
// (possibly allowing for some small leeway to account for clock skew).
if (empty($this->payload['exp'])) {
throw new OidcInvalidTokenException('Missing token expiration time value');
}
$skewSeconds = 120;
$now = time();
if ($now >= (intval($this->payload['exp']) + $skewSeconds)) {
throw new OidcInvalidTokenException('Token has expired');
}
// 6. The iat Claim can be used to reject tokens that were issued too far away from the current time,
// limiting the amount of time that nonces need to be stored to prevent attacks.
// The acceptable range is Client specific.
if (empty($this->payload['iat'])) {
throw new OidcInvalidTokenException('Missing token issued at time value');
}
$dayAgo = time() - 86400;
$iat = intval($this->payload['iat']);
if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) {
throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid');
}
// 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate.
// The meaning and processing of acr Claim Values is out of scope for this document.
// NOTE: Not used for our case here. acr is not requested.
// 8. When a max_age request is made, the Client SHOULD check the auth_time Claim value and request
// re-authentication if it determines too much time has elapsed since the last End-User authentication.
// NOTE: Not used for our case here. A max_age request is not made.
// Custom: Ensure the "sub" (Subject) Claim exists and has a value.
if (empty($this->payload['sub'])) {
throw new OidcInvalidTokenException('Missing token subject value');
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,119 @@
<?php
namespace BookStack\Auth\Access\Oidc;
use phpseclib3\Crypt\Common\PublicKey;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Crypt\RSA;
use phpseclib3\Math\BigInteger;
class OidcJwtSigningKey
{
/**
* @var PublicKey
*/
protected $key;
/**
* Can be created either from a JWK parameter array or local file path to load a certificate from.
* Examples:
* 'file:///var/www/cert.pem'
* ['kty' => 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'].
*
* @param array|string $jwkOrKeyPath
*
* @throws OidcInvalidKeyException
*/
public function __construct($jwkOrKeyPath)
{
if (is_array($jwkOrKeyPath)) {
$this->loadFromJwkArray($jwkOrKeyPath);
} elseif (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) {
$this->loadFromPath($jwkOrKeyPath);
} else {
throw new OidcInvalidKeyException('Unexpected type of key value provided');
}
}
/**
* @throws OidcInvalidKeyException
*/
protected function loadFromPath(string $path)
{
try {
$key = PublicKeyLoader::load(
file_get_contents($path)
);
} catch (\Exception $exception) {
throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}");
}
if (!$key instanceof RSA) {
throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
}
$this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
}
/**
* @throws OidcInvalidKeyException
*/
protected function loadFromJwkArray(array $jwk)
{
// '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'])) {
throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected');
}
if ($jwk['use'] !== 'sig') {
throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
}
if (empty($jwk['e'])) {
throw new OidcInvalidKeyException('An "e" parameter on the provided key is expected');
}
if (empty($jwk['n'])) {
throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected');
}
$n = strtr($jwk['n'] ?? '', '-_', '+/');
try {
$key = PublicKeyLoader::load([
'e' => new BigInteger(base64_decode($jwk['e']), 256),
'n' => new BigInteger(base64_decode($n), 256),
]);
} catch (\Exception $exception) {
throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}");
}
if (!$key instanceof RSA) {
throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
}
$this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
}
/**
* Use this key to sign the given content and return the signature.
*/
public function verify(string $content, string $signature): bool
{
return $this->key->verify($content, $signature);
}
/**
* Convert the key to a PEM encoded key string.
*/
public function toPem(): string
{
return $this->key->toString('PKCS8');
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace BookStack\Auth\Access\Oidc;
use League\OAuth2\Client\Grant\AbstractGrant;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Provider\GenericResourceOwner;
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
use League\OAuth2\Client\Token\AccessToken;
use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
use Psr\Http\Message\ResponseInterface;
/**
* Extended OAuth2Provider for using with OIDC.
* Credit to the https://github.com/steverhoades/oauth2-openid-connect-client
* project for the idea of extending a League\OAuth2 client for this use-case.
*/
class OidcOAuthProvider extends AbstractProvider
{
use BearerAuthorizationTrait;
/**
* @var string
*/
protected $authorizationEndpoint;
/**
* @var string
*/
protected $tokenEndpoint;
/**
* Returns the base URL for authorizing a client.
*/
public function getBaseAuthorizationUrl(): string
{
return $this->authorizationEndpoint;
}
/**
* Returns the base URL for requesting an access token.
*/
public function getBaseAccessTokenUrl(array $params): string
{
return $this->tokenEndpoint;
}
/**
* Returns the URL for requesting the resource owner's details.
*/
public function getResourceOwnerDetailsUrl(AccessToken $token): string
{
return '';
}
/**
* Returns the default scopes used by this provider.
*
* This should only be the scopes that are required to request the details
* of the resource owner, rather than all the available scopes.
*/
protected function getDefaultScopes(): array
{
return ['openid', 'profile', 'email'];
}
/**
* Returns the string that should be used to separate scopes when building
* the URL for requesting an access token.
*/
protected function getScopeSeparator(): string
{
return ' ';
}
/**
* Checks a provider response for errors.
*
* @param ResponseInterface $response
* @param array|string $data Parsed response data
*
* @throws IdentityProviderException
*
* @return void
*/
protected function checkResponse(ResponseInterface $response, $data)
{
if ($response->getStatusCode() >= 400 || isset($data['error'])) {
throw new IdentityProviderException(
$data['error'] ?? $response->getReasonPhrase(),
$response->getStatusCode(),
(string) $response->getBody()
);
}
}
/**
* Generates a resource owner object from a successful resource owner
* details request.
*
* @param array $response
* @param AccessToken $token
*
* @return ResourceOwnerInterface
*/
protected function createResourceOwner(array $response, AccessToken $token)
{
return new GenericResourceOwner($response, '');
}
/**
* Creates an access token from a response.
*
* The grant that was used to fetch the response can be used to provide
* additional context.
*
* @param array $response
* @param AbstractGrant $grant
*
* @return OidcAccessToken
*/
protected function createAccessToken(array $response, AbstractGrant $grant)
{
return new OidcAccessToken($response);
}
}

View File

@@ -0,0 +1,205 @@
<?php
namespace BookStack\Auth\Access\Oidc;
use GuzzleHttp\Psr7\Request;
use Illuminate\Contracts\Cache\Repository;
use InvalidArgumentException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
/**
* OpenIdConnectProviderSettings
* Acts as a DTO for settings used within the oidc request and token handling.
* Performs auto-discovery upon request.
*/
class OidcProviderSettings
{
/**
* @var string
*/
public $issuer;
/**
* @var string
*/
public $clientId;
/**
* @var string
*/
public $clientSecret;
/**
* @var string
*/
public $redirectUri;
/**
* @var string
*/
public $authorizationEndpoint;
/**
* @var string
*/
public $tokenEndpoint;
/**
* @var string[]|array[]
*/
public $keys = [];
public function __construct(array $settings)
{
$this->applySettingsFromArray($settings);
$this->validateInitial();
}
/**
* Apply an array of settings to populate setting properties within this class.
*/
protected function applySettingsFromArray(array $settingsArray)
{
foreach ($settingsArray as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}
/**
* Validate any core, required properties have been set.
*
* @throws InvalidArgumentException
*/
protected function validateInitial()
{
$required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
}
}
if (strpos($this->issuer, 'https://') !== 0) {
throw new InvalidArgumentException('Issuer value must start with https://');
}
}
/**
* Perform a full validation on these settings.
*
* @throws InvalidArgumentException
*/
public function validate(): void
{
$this->validateInitial();
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
}
}
}
/**
* Discover and autoload settings from the configured issuer.
*
* @throws OidcIssuerDiscoveryException
*/
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
{
try {
$cacheKey = 'oidc-discovery::' . $this->issuer;
$discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function () use ($httpClient) {
return $this->loadSettingsFromIssuerDiscovery($httpClient);
});
$this->applySettingsFromArray($discoveredSettings);
} catch (ClientExceptionInterface $exception) {
throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}");
}
}
/**
* @throws OidcIssuerDiscoveryException
* @throws ClientExceptionInterface
*/
protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array
{
$issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration';
$request = new Request('GET', $issuerUrl);
$response = $httpClient->sendRequest($request);
$result = json_decode($response->getBody()->getContents(), true);
if (empty($result) || !is_array($result)) {
throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}");
}
if ($result['issuer'] !== $this->issuer) {
throw new OidcIssuerDiscoveryException('Unexpected issuer value found on discovery response');
}
$discoveredSettings = [];
if (!empty($result['authorization_endpoint'])) {
$discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint'];
}
if (!empty($result['token_endpoint'])) {
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
}
if (!empty($result['jwks_uri'])) {
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
$discoveredSettings['keys'] = $this->filterKeys($keys);
}
return $discoveredSettings;
}
/**
* Filter the given JWK keys down to just those we support.
*/
protected function filterKeys(array $keys): array
{
return array_filter($keys, function (array $key) {
$alg = $key['alg'] ?? null;
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
});
}
/**
* Return an array of jwks as PHP key=>value arrays.
*
* @throws ClientExceptionInterface
* @throws OidcIssuerDiscoveryException
*/
protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array
{
$request = new Request('GET', $uri);
$response = $httpClient->sendRequest($request);
$result = json_decode($response->getBody()->getContents(), true);
if (empty($result) || !is_array($result) || !isset($result['keys'])) {
throw new OidcIssuerDiscoveryException('Error reading keys from issuer jwks_uri');
}
return $result['keys'];
}
/**
* Get the settings needed by an OAuth provider, as a key=>value array.
*/
public function arrayForProvider(): array
{
$settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
$settings = [];
foreach ($settingKeys as $setting) {
$settings[$setting] = $this->$setting;
}
return $settings;
}
}

View File

@@ -0,0 +1,221 @@
<?php
namespace BookStack\Auth\Access\Oidc;
use function auth;
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 Psr\Http\Client\ClientInterface as HttpClient;
use function trans;
use function url;
/**
* Class OpenIdConnectService
* Handles any app-specific OIDC tasks.
*/
class OidcService
{
protected $registrationService;
protected $loginService;
protected $httpClient;
/**
* OpenIdService constructor.
*/
public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
{
$this->registrationService = $registrationService;
$this->loginService = $loginService;
$this->httpClient = $httpClient;
}
/**
* Initiate an authorization flow.
*
* @return array{url: string, state: string}
*/
public function login(): array
{
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
return [
'url' => $provider->getAuthorizationUrl(),
'state' => $provider->getState(),
];
}
/**
* 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.
*
* @throws Exception
* @throws ClientExceptionInterface
*/
public function processAuthorizeResponse(?string $authorizationCode): ?User
{
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
// Try to exchange authorization code for access token
$accessToken = $provider->getAccessToken('authorization_code', [
'code' => $authorizationCode,
]);
return $this->processAccessTokenCallback($accessToken, $settings);
}
/**
* @throws OidcIssuerDiscoveryException
* @throws ClientExceptionInterface
*/
protected function getProviderSettings(): OidcProviderSettings
{
$config = $this->config();
$settings = new OidcProviderSettings([
'issuer' => $config['issuer'],
'clientId' => $config['client_id'],
'clientSecret' => $config['client_secret'],
'redirectUri' => url('/oidc/callback'),
'authorizationEndpoint' => $config['authorization_endpoint'],
'tokenEndpoint' => $config['token_endpoint'],
]);
// Use keys if configured
if (!empty($config['jwt_public_key'])) {
$settings->keys = [$config['jwt_public_key']];
}
// Run discovery
if ($config['discover'] ?? false) {
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
}
$settings->validate();
return $settings;
}
/**
* Load the underlying OpenID Connect Provider.
*/
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{
return new OidcOAuthProvider($settings->arrayForProvider(), [
'httpClient' => $this->httpClient,
'optionProvider' => new HttpBasicAuthOptionProvider(),
]);
}
/**
* Calculate the display name.
*/
protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
{
$displayNameAttr = $this->config()['display_name_claims'];
$displayName = [];
foreach ($displayNameAttr as $dnAttr) {
$dnComponent = $token->getClaim($dnAttr) ?? '';
if ($dnComponent !== '') {
$displayName[] = $dnComponent;
}
}
if (count($displayName) == 0) {
$displayName[] = $defaultValue;
}
return implode(' ', $displayName);
}
/**
* Extract the details of a user from an ID token.
*
* @return array{name: string, email: string, external_id: string}
*/
protected function getUserDetails(OidcIdToken $token): array
{
$id = $token->getClaim('sub');
return [
'external_id' => $id,
'email' => $token->getClaim('email'),
'name' => $this->getUserDisplayName($token, $id),
];
}
/**
* Processes a received access token for a user. Login the user when
* they exist, optionally registering them automatically.
*
* @throws OpenIdConnectException
* @throws JsonDebugException
* @throws UserRegistrationException
* @throws StoppedAuthenticationException
*/
protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
{
$idTokenText = $accessToken->getIdToken();
$idToken = new OidcIdToken(
$idTokenText,
$settings->issuer,
$settings->keys,
);
if ($this->config()['dump_user_details']) {
throw new JsonDebugException($idToken->getAllClaims());
}
try {
$idToken->validate($settings->clientId);
} catch (OidcInvalidTokenException $exception) {
throw new OpenIdConnectException("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'));
}
if ($isLoggedIn) {
throw new OpenIdConnectException(trans('errors.oidc_already_logged_in'), '/login');
}
$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');
}
$this->loginService->login($user, 'oidc');
return $user;
}
/**
* Get the OIDC config from the application.
*/
protected function config(): array
{
return config('oidc');
}
}

View File

@@ -11,6 +11,7 @@ use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Exception;
use Illuminate\Support\Str;
class RegistrationService
{
@@ -50,6 +51,32 @@ class RegistrationService
return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled');
}
/**
* Attempt to find a user in the system otherwise register them as a new
* user. For use with external auth systems since password is auto-generated.
*
* @throws UserRegistrationException
*/
public function findOrRegister(string $name, string $email, string $externalId): User
{
$user = User::query()
->where('external_auth_id', '=', $externalId)
->first();
if (is_null($user)) {
$userData = [
'name' => $name,
'email' => $email,
'password' => Str::random(32),
'external_auth_id' => $externalId,
];
$user = $this->registerUser($userData, null, false);
}
return $user;
}
/**
* The registrations flow for all users.
*

View File

@@ -8,8 +8,8 @@ use BookStack\Exceptions\SamlException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use Exception;
use Illuminate\Support\Str;
use OneLogin\Saml2\Auth;
use OneLogin\Saml2\Constants;
use OneLogin\Saml2\Error;
use OneLogin\Saml2\IdPMetadataParser;
use OneLogin\Saml2\ValidationError;
@@ -18,20 +18,25 @@ use OneLogin\Saml2\ValidationError;
* Class Saml2Service
* Handles any app-specific SAML tasks.
*/
class Saml2Service extends ExternalAuthService
class Saml2Service
{
protected $config;
protected $registrationService;
protected $loginService;
protected $groupSyncService;
/**
* Saml2Service constructor.
*/
public function __construct(RegistrationService $registrationService, LoginService $loginService)
{
public function __construct(
RegistrationService $registrationService,
LoginService $loginService,
GroupSyncService $groupSyncService
) {
$this->config = config('saml2');
$this->registrationService = $registrationService;
$this->loginService = $loginService;
$this->groupSyncService = $groupSyncService;
}
/**
@@ -55,13 +60,20 @@ class Saml2Service extends ExternalAuthService
*
* @throws Error
*/
public function logout(): array
public function logout(User $user): array
{
$toolKit = $this->getToolkit();
$returnRoute = url('/');
try {
$url = $toolKit->logout($returnRoute, [], null, null, true);
$url = $toolKit->logout(
$returnRoute,
[],
$user->email,
null,
true,
Constants::NAMEID_EMAIL_ADDRESS
);
$id = $toolKit->getLastRequestID();
} catch (Error $error) {
if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) {
@@ -87,8 +99,11 @@ class Saml2Service extends ExternalAuthService
* @throws JsonDebugException
* @throws UserRegistrationException
*/
public function processAcsResponse(?string $requestId): ?User
public function processAcsResponse(?string $requestId, string $samlResponse): ?User
{
// The SAML2 toolkit expects the response to be within the $_POST superglobal
// so we need to manually put it back there at this point.
$_POST['SAMLResponse'] = $samlResponse;
$toolkit = $this->getToolkit();
$toolkit->processResponse($requestId);
$errors = $toolkit->getErrors();
@@ -117,8 +132,13 @@ class Saml2Service extends ExternalAuthService
public function processSlsResponse(?string $requestId): ?string
{
$toolkit = $this->getToolkit();
$redirect = $toolkit->processSLO(true, $requestId, false, null, true);
// The $retrieveParametersFromServer in the call below will mean the library will take the query
// parameters, used for the response signing, from the raw $_SERVER['QUERY_STRING']
// value so that the exact encoding format is matched when checking the signature.
// This is primarily due to ADFS encoding query params with lowercase percent encoding while
// PHP (And most other sensible providers) standardise on uppercase.
$redirect = $toolkit->processSLO(true, $requestId, true, null, true);
$errors = $toolkit->getErrors();
if (!empty($errors)) {
@@ -258,6 +278,8 @@ class Saml2Service extends ExternalAuthService
/**
* Extract the details of a user from a SAML response.
*
* @return array{external_id: string, name: string, email: string, saml_id: string}
*/
protected function getUserDetails(string $samlID, $samlAttributes): array
{
@@ -322,31 +344,6 @@ class Saml2Service extends ExternalAuthService
return $defaultValue;
}
/**
* Get the user from the database for the specified details.
*
* @throws UserRegistrationException
*/
protected function getOrRegisterUser(array $userDetails): ?User
{
$user = User::query()
->where('external_auth_id', '=', $userDetails['external_id'])
->first();
if (is_null($user)) {
$userData = [
'name' => $userDetails['name'],
'email' => $userDetails['email'],
'password' => Str::random(32),
'external_auth_id' => $userDetails['external_id'],
];
$user = $this->registrationService->registerUser($userData, null, false);
}
return $user;
}
/**
* Process the SAML response for a user. Login the user when
* they exist, optionally registering them automatically.
@@ -377,14 +374,19 @@ class Saml2Service extends ExternalAuthService
throw new SamlException(trans('errors.saml_already_logged_in'), '/login');
}
$user = $this->getOrRegisterUser($userDetails);
$user = $this->registrationService->findOrRegister(
$userDetails['name'],
$userDetails['email'],
$userDetails['external_id']
);
if ($user === null) {
throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
}
if ($this->shouldSyncGroups()) {
$groups = $this->getUserGroups($samlAttributes);
$this->syncWithGroups($user, $groups);
$this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']);
}
$this->loginService->login($user, 'saml2');

View File

@@ -12,6 +12,7 @@ use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite;
use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Contracts\User as SocialUser;
use Laravel\Socialite\Two\GoogleProvider;
use SocialiteProviders\Manager\SocialiteWasCalled;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -141,7 +142,7 @@ class SocialAuthService
// When a user is not logged in and a matching SocialAccount exists,
// Simply log the user into the application.
if (!$isLoggedIn && $socialAccount !== null) {
$this->loginService->login($socialAccount->user, $socialAccount);
$this->loginService->login($socialAccount->user, $socialDriver);
return redirect()->intended('/');
}
@@ -278,12 +279,9 @@ class SocialAuthService
{
$driver = $this->socialite->driver($driverName);
if ($driverName === 'google' && config('services.google.select_account')) {
if ($driver instanceof GoogleProvider && config('services.google.select_account')) {
$driver->with(['prompt' => 'select_account']);
}
if ($driverName === 'azure') {
$driver->with(['resource' => 'https://graph.windows.net']);
}
if (isset($this->configureForRedirectCallbacks[$driverName])) {
$this->configureForRedirectCallbacks[$driverName]($driver);

View File

@@ -602,24 +602,35 @@ class PermissionService
/**
* Filter items that have entities set as a polymorphic relation.
* For simplicity, this will not return results attached to draft pages.
* Draft pages should never really have related items though.
*
* @param Builder|\Illuminate\Database\Query\Builder $query
* @param Builder|QueryBuilder $query
*/
public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
{
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$pageMorphClass = (new Page())->getMorphClass();
$q = $query->where(function ($query) use ($tableDetails, $action) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
$permissionQuery->select(['role_id'])->from('joint_permissions')
->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->where('action', '=', $action)
->whereIn('role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
});
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
/** @var Builder $permissionQuery */
$permissionQuery->select(['role_id'])->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->where('joint_permissions.action', '=', $action)
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
})->where(function ($query) use ($tableDetails, $pageMorphClass) {
/** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
->where('pages.draft', '=', false);
});
});
$this->clean();
@@ -633,24 +644,39 @@ class PermissionService
*/
public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder
{
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
$morphClass = app($entityClass)->getMorphClass();
$fullEntityIdColumn = $tableName . '.' . $entityIdColumn;
$instance = new $entityClass();
$morphClass = $instance->getMorphClass();
$q = $query->where(function ($query) use ($tableDetails, $morphClass) {
$query->where(function ($query) use (&$tableDetails, $morphClass) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails, $morphClass) {
$permissionQuery->select('id')->from('joint_permissions')
->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where('entity_type', '=', $morphClass)
->where('action', '=', 'view')
->whereIn('role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
$existsQuery = function ($permissionQuery) use ($fullEntityIdColumn, $morphClass) {
/** @var Builder $permissionQuery */
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
->whereColumn('joint_permissions.entity_id', '=', $fullEntityIdColumn)
->where('joint_permissions.entity_type', '=', $morphClass)
->where('joint_permissions.action', '=', 'view')
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
})->orWhere($tableDetails['entityIdColumn'], '=', 0);
};
$q = $query->where(function ($query) use ($existsQuery, $fullEntityIdColumn) {
$query->whereExists($existsQuery)
->orWhere($fullEntityIdColumn, '=', 0);
});
if ($instance instanceof Page) {
// Prevent visibility of non-owned draft pages
$q->whereExists(function (QueryBuilder $query) use ($fullEntityIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullEntityIdColumn)
->where(function (QueryBuilder $query) {
$query->where('pages.draft', '=', false)
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
});
});
}
$this->clean();
return $q;
@@ -664,9 +690,9 @@ class PermissionService
*/
protected function addJointHasPermissionCheck($query, int $userIdToCheck)
{
$query->where('has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $userIdToCheck);
$query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
$query->where('joint_permissions.has_permission_own', '=', true)
->where('joint_permissions.owned_by', '=', $userIdToCheck);
});
}

View File

@@ -4,6 +4,7 @@ namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
/**
* @property int $id
@@ -13,19 +14,15 @@ class RolePermission extends Model
/**
* The roles that belong to the permission.
*/
public function roles()
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class, 'permission_role', 'permission_id', 'role_id');
}
/**
* Get the permission object by name.
*
* @param $name
*
* @return mixed
*/
public static function getByName($name)
public static function getByName(string $name): ?RolePermission
{
return static::where('name', '=', $name)->first();
}

View File

@@ -7,21 +7,25 @@ use BookStack\Auth\Permissions\RolePermission;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Class Role.
*
* @property int $id
* @property string $display_name
* @property string $description
* @property string $external_auth_id
* @property string $system_name
* @property bool $mfa_enforced
* @property int $id
* @property string $display_name
* @property string $description
* @property string $external_auth_id
* @property string $system_name
* @property bool $mfa_enforced
* @property Collection $users
*/
class Role extends Model implements Loggable
{
use HasFactory;
protected $fillable = ['display_name', 'description', 'external_auth_id'];
/**
@@ -82,7 +86,7 @@ class Role extends Model implements Loggable
/**
* Get the role of the specified display name.
*/
public static function getRole(string $displayName): ?Role
public static function getRole(string $displayName): ?self
{
return static::query()->where('display_name', '=', $displayName)->first();
}
@@ -90,7 +94,7 @@ class Role extends Model implements Loggable
/**
* Get the role object for the specified system role.
*/
public static function getSystemRole(string $systemName): ?Role
public static function getSystemRole(string $systemName): ?self
{
return static::query()->where('system_name', '=', $systemName)->first();
}
@@ -115,7 +119,7 @@ class Role extends Model implements Loggable
}
/**
* @inheritdoc
* {@inheritdoc}
*/
public function logDescriptor(): string
{

View File

@@ -21,7 +21,7 @@ class SocialAccount extends Model implements Loggable
}
/**
* @inheritDoc
* {@inheritdoc}
*/
public function logDescriptor(): string
{

View File

@@ -18,6 +18,7 @@ use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -27,7 +28,7 @@ use Illuminate\Support\Collection;
/**
* Class User.
*
* @property string $id
* @property int $id
* @property string $name
* @property string $slug
* @property string $email
@@ -43,6 +44,7 @@ use Illuminate\Support\Collection;
*/
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable
{
use HasFactory;
use Authenticatable;
use CanResetPassword;
use Notifiable;
@@ -90,7 +92,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Returns the default public user.
*/
public static function getDefault(): User
public static function getDefault(): self
{
if (!is_null(static::$defaultUser)) {
return static::$defaultUser;
@@ -144,7 +146,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);
}
@@ -176,7 +178,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
->leftJoin('permission_role', 'ru.role_id', '=', 'permission_role.role_id')
->leftJoin('role_permissions', 'permission_role.permission_id', '=', 'role_permissions.id')
->where('ru.user_id', '=', $this->id)
->get()
->pluck('name');
return $this->permissions;
@@ -336,7 +337,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}
/**
* @inheritdoc
* {@inheritdoc}
*/
public function logDescriptor(): string
{
@@ -344,7 +345,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}
/**
* @inheritDoc
* {@inheritdoc}
*/
public function refreshSlug(): string
{

View File

@@ -2,7 +2,6 @@
namespace BookStack\Auth;
use Activity;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
@@ -15,7 +14,7 @@ use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Log;
use Illuminate\Support\Facades\Log;
class UserRepo
{
@@ -63,13 +62,16 @@ class UserRepo
/**
* 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'];
$query = User::query()->select(['*'])
->withLastActivityAt()
->scopes(['withLastActivityAt'])
->with(['roles', 'avatar'])
->withCount('mfaValues')
->orderBy($sort, $sortData['order']);
@@ -215,14 +217,6 @@ class UserRepo
}
}
/**
* Get the latest activity for a user.
*/
public function getActivity(User $user, int $count = 20, int $page = 0): array
{
return Activity::userActivity($user, $count, $page);
}
/**
* Get the recently created content for this given user.
*/

16
app/Config/app.php Executable file → Normal file
View File

@@ -31,6 +31,9 @@ return [
// Set to -1 for unlimited recycle bin lifetime.
'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30),
// The limit for all uploaded files, including images and attachments in MB.
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
// Allow <script> tags to entered within page content.
// <script> tags are escaped by default.
// Even when overridden the WYSIWYG editor may still escape script content.
@@ -61,7 +64,7 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', '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', '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'],
// Application Fallback Locale
'fallback_locale' => 'en',
@@ -143,7 +146,6 @@ return [
// Class aliases, Registered on application start
'aliases' => [
// Laravel
'App' => Illuminate\Support\Facades\App::class,
'Arr' => Illuminate\Support\Arr::class,
@@ -155,21 +157,23 @@ return [
'Config' => Illuminate\Support\Facades\Config::class,
'Cookie' => Illuminate\Support\Facades\Cookie::class,
'Crypt' => Illuminate\Support\Facades\Crypt::class,
'Date' => Illuminate\Support\Facades\Date::class,
'DB' => Illuminate\Support\Facades\DB::class,
'Eloquent' => Illuminate\Database\Eloquent\Model::class,
'Event' => Illuminate\Support\Facades\Event::class,
'File' => Illuminate\Support\Facades\File::class,
'Gate' => Illuminate\Support\Facades\Gate::class,
'Hash' => Illuminate\Support\Facades\Hash::class,
'Input' => Illuminate\Support\Facades\Input::class,
'Inspiring' => Illuminate\Foundation\Inspiring::class,
'Http' => Illuminate\Support\Facades\Http::class,
'Lang' => Illuminate\Support\Facades\Lang::class,
'Log' => Illuminate\Support\Facades\Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class,
'Notification' => Illuminate\Support\Facades\Notification::class,
'Password' => Illuminate\Support\Facades\Password::class,
'Queue' => Illuminate\Support\Facades\Queue::class,
'RateLimiter' => Illuminate\Support\Facades\RateLimiter::class,
'Redirect' => Illuminate\Support\Facades\Redirect::class,
'Redis' => Illuminate\Support\Facades\Redis::class,
// 'Redis' => Illuminate\Support\Facades\Redis::class,
'Request' => Illuminate\Support\Facades\Request::class,
'Response' => Illuminate\Support\Facades\Response::class,
'Route' => Illuminate\Support\Facades\Route::class,
@@ -180,6 +184,8 @@ return [
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
// Laravel Packages
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
// Third Party

View File

@@ -10,8 +10,7 @@
return [
// Method of authentication to use
// Options: standard, ldap, saml2
// Options: standard, ldap, saml2, oidc
'method' => env('AUTH_METHOD', 'standard'),
// Authentication Defaults
@@ -26,7 +25,7 @@ return [
// All authentication drivers have a user provider. This defines how the
// users are actually retrieved out of your database or other storage
// mechanisms used by this application to persist your user's data.
// Supported drivers: "session", "api-token", "ldap-session"
// Supported drivers: "session", "api-token", "ldap-session", "async-external-session"
'guards' => [
'standard' => [
'driver' => 'session',
@@ -37,11 +36,15 @@ return [
'provider' => 'external',
],
'saml2' => [
'driver' => 'saml2-session',
'driver' => 'async-external-session',
'provider' => 'external',
],
'oidc' => [
'driver' => 'async-external-session',
'provider' => 'external',
],
'api' => [
'driver' => 'api-token',
'driver' => 'api-token',
],
],
@@ -54,10 +57,16 @@ return [
'driver' => 'eloquent',
'model' => \BookStack\Auth\User::class,
],
'external' => [
'driver' => 'external-users',
'model' => \BookStack\Auth\User::class,
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
// Resetting Passwords
@@ -70,7 +79,14 @@ return [
'email' => 'emails.password',
'table' => 'password_resets',
'expire' => 60,
'throttle' => 60,
],
],
// Password Confirmation Timeout
// Here you may define the amount of seconds before a password confirmation
// times out and the user is prompted to re-enter their password via the
// confirmation screen. By default, the timeout lasts for three hours.
'password_timeout' => 10800,
];

View File

@@ -1,5 +1,7 @@
<?php
use Illuminate\Support\Str;
/**
* Caching configuration options.
*
@@ -38,13 +40,15 @@ return [
],
'array' => [
'driver' => 'array',
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'table' => 'cache',
'connection' => null,
'driver' => 'database',
'table' => 'cache',
'connection' => null,
'lock_connection' => null,
],
'file' => [
@@ -53,19 +57,36 @@ return [
],
'memcached' => [
'driver' => 'memcached',
'servers' => env('CACHE_DRIVER') === 'memcached' ? $memcachedServers : [],
'driver' => 'memcached',
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => $memcachedServers ?? [],
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'driver' => 'redis',
'connection' => 'default',
'lock_connection' => 'default',
],
'octane' => [
'driver' => 'octane',
],
],
// Cache key prefix
// Used to prevent collisions in shared cache systems.
'prefix' => env('CACHE_PREFIX', 'bookstack_cache'),
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing a RAM based store such as APC or Memcached, there might
| be other applications utilizing the same cache. So, we'll specify a
| value to get prefixed to all our keys so we can avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache'),
];

415
app/Config/clockwork.php Normal file
View File

@@ -0,0 +1,415 @@
<?php
return [
/*
|------------------------------------------------------------------------------------------------------------------
| Enable Clockwork
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork is enabled by default only when your application is in debug mode. Here you can explicitly enable or
| disable Clockwork. When disabled, no data is collected and the api and web ui are inactive.
|
*/
'enable' => env('CLOCKWORK_ENABLE', false),
/*
|------------------------------------------------------------------------------------------------------------------
| Features
|------------------------------------------------------------------------------------------------------------------
|
| You can enable or disable various Clockwork features here. Some features have additional settings (eg. slow query
| threshold for database queries).
|
*/
'features' => [
// Cache usage stats and cache queries including results
'cache' => [
'enabled' => true,
// Collect cache queries
'collect_queries' => true,
// Collect values from cache queries (high performance impact with a very high number of queries)
'collect_values' => false,
],
// Database usage stats and queries
'database' => [
'enabled' => true,
// Collect database queries (high performance impact with a very high number of queries)
'collect_queries' => true,
// Collect details of models updates (high performance impact with a lot of model updates)
'collect_models_actions' => true,
// Collect details of retrieved models (very high performance impact with a lot of models retrieved)
'collect_models_retrieved' => false,
// Query execution time threshold in miliseconds after which the query will be marked as slow
'slow_threshold' => null,
// Collect only slow database queries
'slow_only' => false,
// Detect and report duplicate (N+1) queries
'detect_duplicate_queries' => false,
],
// Dispatched events
'events' => [
'enabled' => true,
// Ignored events (framework events are ignored by default)
'ignored_events' => [
// App\Events\UserRegistered::class,
// 'user.registered'
],
],
// Laravel log (you can still log directly to Clockwork with laravel log disabled)
'log' => [
'enabled' => true,
],
// Sent notifications
'notifications' => [
'enabled' => true,
],
// Performance metrics
'performance' => [
// Allow collecting of client metrics. Requires separate clockwork-browser npm package.
'client_metrics' => true,
],
// Dispatched queue jobs
'queue' => [
'enabled' => true,
],
// Redis commands
'redis' => [
'enabled' => true,
],
// Routes list
'routes' => [
'enabled' => false,
// Collect only routes from particular namespaces (only application routes by default)
'only_namespaces' => ['App'],
],
// Rendered views
'views' => [
'enabled' => true,
// Collect views including view data (high performance impact with a high number of views)
'collect_data' => false,
// Use Twig profiler instead of Laravel events for apps using laravel-twigbridge (more precise, but does
// not support collecting view data)
'use_twig_profiler' => false,
],
],
/*
|------------------------------------------------------------------------------------------------------------------
| Enable web UI
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork comes with a web UI accessibla via http://your.app/clockwork. Here you can enable or disable this
| feature. You can also set a custom path for the web UI.
|
*/
'web' => true,
/*
|------------------------------------------------------------------------------------------------------------------
| Enable toolbar
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can show a toolbar with basic metrics on all responses. Here you can enable or disable this feature.
| Requires a separate clockwork-browser npm library.
| For installation instructions see https://underground.works/clockwork/#docs-viewing-data
|
*/
'toolbar' => true,
/*
|------------------------------------------------------------------------------------------------------------------
| HTTP requests collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork collects data about HTTP requests to your app. Here you can choose which requests should be collected.
|
*/
'requests' => [
// With on-demand mode enabled, Clockwork will only profile requests when the browser extension is open or you
// manually pass a "clockwork-profile" cookie or get/post data key.
// Optionally you can specify a "secret" that has to be passed as the value to enable profiling.
'on_demand' => false,
// Collect only errors (requests with HTTP 4xx and 5xx responses)
'errors_only' => false,
// Response time threshold in miliseconds after which the request will be marked as slow
'slow_threshold' => null,
// Collect only slow requests
'slow_only' => false,
// Sample the collected requests (eg. set to 100 to collect only 1 in 100 requests)
'sample' => false,
// List of URIs that should not be collected
'except' => [
'/horizon/.*', // Laravel Horizon requests
'/telescope/.*', // Laravel Telescope requests
'/_debugbar/.*', // Laravel DebugBar requests
],
// List of URIs that should be collected, any other URI will not be collected if not empty
'only' => [
// '/api/.*'
],
// Don't collect OPTIONS requests, mostly used in the CSRF pre-flight requests and are rarely of interest
'except_preflight' => true,
],
/*
|------------------------------------------------------------------------------------------------------------------
| Artisan commands collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can collect data about executed artisan commands. Here you can enable and configure which commands
| should be collected.
|
*/
'artisan' => [
// Enable or disable collection of executed Artisan commands
'collect' => false,
// List of commands that should not be collected (built-in commands are not collected by default)
'except' => [
// 'inspire'
],
// List of commands that should be collected, any other command will not be collected if not empty
'only' => [
// 'inspire'
],
// Enable or disable collection of command output
'collect_output' => false,
// Enable or disable collection of built-in Laravel commands
'except_laravel_commands' => true,
],
/*
|------------------------------------------------------------------------------------------------------------------
| Queue jobs collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can collect data about executed queue jobs. Here you can enable and configure which queue jobs should
| be collected.
|
*/
'queue' => [
// Enable or disable collection of executed queue jobs
'collect' => false,
// List of queue jobs that should not be collected
'except' => [
// App\Jobs\ExpensiveJob::class
],
// List of queue jobs that should be collected, any other queue job will not be collected if not empty
'only' => [
// App\Jobs\BuggyJob::class
],
],
/*
|------------------------------------------------------------------------------------------------------------------
| Tests collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can collect data about executed tests. Here you can enable and configure which tests should be
| collected.
|
*/
'tests' => [
// Enable or disable collection of ran tests
'collect' => false,
// List of tests that should not be collected
'except' => [
// Tests\Unit\ExampleTest::class
],
],
/*
|------------------------------------------------------------------------------------------------------------------
| Enable data collection when Clockwork is disabled
|------------------------------------------------------------------------------------------------------------------
|
| You can enable this setting to collect data even when Clockwork is disabled. Eg. for future analysis.
|
*/
'collect_data_always' => false,
/*
|------------------------------------------------------------------------------------------------------------------
| Metadata storage
|------------------------------------------------------------------------------------------------------------------
|
| Configure how is the metadata collected by Clockwork stored. Two options are available:
| - files - A simple fast storage implementation storing data in one-per-request files.
| - sql - Stores requests in a sql database. Supports MySQL, Postgresql, Sqlite and requires PDO.
|
*/
'storage' => 'files',
// Path where the Clockwork metadata is stored
'storage_files_path' => storage_path('clockwork'),
// Compress the metadata files using gzip, trading a little bit of performance for lower disk usage
'storage_files_compress' => false,
// SQL database to use, can be a name of database configured in database.php or a path to a sqlite file
'storage_sql_database' => storage_path('clockwork.sqlite'),
// SQL table name to use, the table is automatically created and udpated when needed
'storage_sql_table' => 'clockwork',
// Maximum lifetime of collected metadata in minutes, older requests will automatically be deleted, false to disable
'storage_expiration' => 60 * 24 * 7,
/*
|------------------------------------------------------------------------------------------------------------------
| Authentication
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can be configured to require authentication before allowing access to the collected data. This might be
| useful when the application is publicly accessible. Setting to true will enable a simple authentication with a
| pre-configured password. You can also pass a class name of a custom implementation.
|
*/
'authentication' => false,
// Password for the simple authentication
'authentication_password' => 'VerySecretPassword',
/*
|------------------------------------------------------------------------------------------------------------------
| Stack traces collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can collect stack traces for log messages and certain data like database queries. Here you can set
| whether to collect stack traces, limit the number of collected frames and set further configuration. Collecting
| long stack traces considerably increases metadata size.
|
*/
'stack_traces' => [
// Enable or disable collecting of stack traces
'enabled' => true,
// Limit the number of frames to be collected
'limit' => 10,
// List of vendor names to skip when determining caller, common vendors are automatically added
'skip_vendors' => [
// 'phpunit'
],
// List of namespaces to skip when determining caller
'skip_namespaces' => [
// 'Laravel'
],
// List of class names to skip when determining caller
'skip_classes' => [
// App\CustomLog::class
],
],
/*
|------------------------------------------------------------------------------------------------------------------
| Serialization
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork serializes the collected data to json for storage and transfer. Here you can configure certain aspects
| of serialization. Serialization has a large effect on the cpu time and memory usage.
|
*/
// Maximum depth of serialized multi-level arrays and objects
'serialization_depth' => 10,
// A list of classes that will never be serialized (eg. a common service container class)
'serialization_blackbox' => [
\Illuminate\Container\Container::class,
\Illuminate\Foundation\Application::class,
],
/*
|------------------------------------------------------------------------------------------------------------------
| Register helpers
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork comes with a "clock" global helper function. You can use this helper to quickly log something and to
| access the Clockwork instance.
|
*/
'register_helpers' => true,
/*
|------------------------------------------------------------------------------------------------------------------
| Send Headers for AJAX request
|------------------------------------------------------------------------------------------------------------------
|
| When trying to collect data the AJAX method can sometimes fail if it is missing required headers. For example, an
| API might require a version number using Accept headers to route the HTTP request to the correct codebase.
|
*/
'headers' => [
// 'Accept' => 'application/vnd.com.whatever.v1+json',
],
/*
|------------------------------------------------------------------------------------------------------------------
| Server-Timing
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork supports the W3C Server Timing specification, which allows for collecting a simple performance metrics
| in a cross-browser way. Eg. in Chrome, your app, database and timeline event timings will be shown in the Dev
| Tools network tab. This setting specifies the max number of timeline events that will be sent. Setting to false
| will disable the feature.
|
*/
'server_timing' => 10,
];

View File

@@ -69,7 +69,10 @@ return [
'port' => $mysql_port,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
// Prefixes are only semi-supported and may be unstable
// since they are not tested as part of our automated test suite.
// If used, the prefix should not be changed otherwise you will likely receive errors.
'prefix' => env('DB_TABLE_PREFIX', ''),
'prefix_indexes' => true,
'strict' => false,
'engine' => null,
@@ -102,6 +105,6 @@ return [
'migrations' => 'migrations',
// Redis configuration to use if set
'redis' => env('REDIS_SERVERS', false) ? $redisConfig : [],
'redis' => $redisConfig ?? [],
];

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 [
@@ -70,7 +74,7 @@ return [
* direct class use like:
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
*/
'chroot' => realpath(base_path()),
'chroot' => realpath(public_path()),
/**
* Whether to use Unicode fonts or not.
@@ -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

@@ -25,21 +25,25 @@ return [
// file storage service, such as s3, to store publicly accessible assets.
'url' => env('STORAGE_URL', false),
// Default Cloud Filesystem Disk
'cloud' => 's3',
// Available filesystem disks
// Only local, local_secure & s3 are supported by BookStack
'disks' => [
'local' => [
'driver' => 'local',
'root' => public_path(),
'driver' => 'local',
'root' => public_path(),
'visibility' => 'public',
],
'local_secure' => [
'local_secure_attachments' => [
'driver' => 'local',
'root' => storage_path(),
'root' => storage_path('uploads/files/'),
],
'local_secure_images' => [
'driver' => 'local',
'root' => storage_path('uploads/images/'),
'visibility' => 'public',
],
's3' => [
@@ -54,4 +58,12 @@ return [
],
// Symbolic Links
// Here you may configure the symbolic links that will be created when the
// `storage:link` Artisan command is executed. The array keys should be
// the locations of the links and the values should be their targets.
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

View File

@@ -49,16 +49,9 @@ return [
'days' => 7,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => 'critical',
],
'stderr' => [
'driver' => 'monolog',
'level' => 'debug',
'handler' => StreamHandler::class,
'with' => [
'stream' => 'php://stderr',
@@ -99,6 +92,10 @@ return [
'testing' => [
'driver' => 'testing',
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
// Failed Login Message

View File

@@ -11,6 +11,8 @@
return [
// Mail driver to use.
// From Laravel 7+ this is MAIL_MAILER in laravel.
// Kept as MAIL_DRIVER in BookStack to prevent breaking change.
// Options: smtp, sendmail, log, array
'driver' => env('MAIL_DRIVER', 'smtp'),

35
app/Config/oidc.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
return [
// Display name, shown to users, for OpenId option
'name' => env('OIDC_NAME', 'SSO'),
// Dump user details after a login request for debugging purposes
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
// Attribute, within a OpenId token, to find the user's display name
'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')),
// OAuth2/OpenId client id, as configured in your Authorization server.
'client_id' => env('OIDC_CLIENT_ID', null),
// OAuth2/OpenId client secret, as configured in your Authorization server.
'client_secret' => env('OIDC_CLIENT_SECRET', null),
// The issuer of the identity token (id_token) this will be compared with
// what is returned in the token.
'issuer' => env('OIDC_ISSUER', null),
// Auto-discover the relevant endpoints and keys from the issuer.
// Fetched details are cached for 15 minutes.
'discover' => env('OIDC_ISSUER_DISCOVER', false),
// Public key that's used to verify the JWT token with.
// Can be the key value itself or a local 'file://public.key' reference.
'jwt_public_key' => env('OIDC_PUBLIC_KEY', null),
// OAuth2 endpoints.
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
];

View File

@@ -11,7 +11,7 @@
return [
// Default driver to use for the queue
// Options: null, sync, redis
// Options: sync, database, redis
'default' => env('QUEUE_CONNECTION', 'sync'),
// Queue connection configuration
@@ -22,25 +22,29 @@ return [
],
'database' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'after_commit' => false,
],
],
// Failed queue job logging
'failed' => [
'database' => 'mysql', 'table' => 'failed_jobs',
'driver' => 'database-uuids',
'database' => 'mysql',
'table' => 'failed_jobs',
],
];

View File

@@ -1,6 +1,7 @@
<?php
$SAML2_IDP_AUTHNCONTEXT = env('SAML2_IDP_AUTHNCONTEXT', true);
$SAML2_SP_x509 = env('SAML2_SP_x509', false);
return [
@@ -78,10 +79,11 @@ return [
// represent the requested subject.
// Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported
'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
// Usually x509cert and privateKey of the SP are provided by files placed at
// the certs folder. But we can also provide them with the following parameters
'x509cert' => '',
'privateKey' => '',
'x509cert' => $SAML2_SP_x509 ?: '',
'privateKey' => env('SAML2_SP_x509_KEY', ''),
],
// Identity Provider Data that we want connect with our SP
'idp' => [
@@ -147,6 +149,11 @@ return [
// Multiple forced values can be passed via a space separated array, For example:
// SAML2_IDP_AUTHNCONTEXT="urn:federation:authentication:windows urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
'requestedAuthnContext' => is_string($SAML2_IDP_AUTHNCONTEXT) ? explode(' ', $SAML2_IDP_AUTHNCONTEXT) : $SAML2_IDP_AUTHNCONTEXT,
// Sign requests and responses if a certificate is in use
'logoutRequestSigned' => (bool) $SAML2_SP_x509,
'logoutResponseSigned' => (bool) $SAML2_SP_x509,
'authnRequestsSigned' => (bool) $SAML2_SP_x509,
'lowercaseUrlencoding' => 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

@@ -3,7 +3,13 @@
namespace BookStack\Console\Commands;
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;
class CreateAdmin extends Command
{
@@ -15,7 +21,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.
@@ -38,46 +45,63 @@ class CreateAdmin extends Command
/**
* Execute the console command.
*
* @throws \BookStack\Exceptions\NotFoundException
* @throws NotFoundException
*
* @return mixed
*/
public function handle()
{
$email = trim($this->option('email'));
if (empty($email)) {
$email = $this->ask('Please specify an email address for the new admin user');
}
if (mb_strlen($email) < 5 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->error('Invalid email address provided');
$details = $this->snakeCaseOptions();
if (empty($details['email'])) {
$details['email'] = $this->ask('Please specify an email address for the new admin user');
}
if ($this->userRepo->getByEmail($email) !== null) {
return $this->error('A user with the provided email already exists!');
if (empty($details['name'])) {
$details['name'] = $this->ask('Please specify a name for the new admin user');
}
$name = trim($this->option('name'));
if (empty($name)) {
$name = $this->ask('Please specify an name for the new admin user');
}
if (mb_strlen($name) < 2) {
return $this->error('Invalid name provided');
if (empty($details['password'])) {
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);
}
}
$password = trim($this->option('password'));
if (empty($password)) {
$password = $this->secret('Please specify a password for the new admin user');
}
if (mb_strlen($password) < 5) {
return $this->error('Invalid password provided, Must be at least 5 characters');
$validator = Validator::make($details, [
'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()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return SymfonyCommand::FAILURE;
}
$user = $this->userRepo->create(['email' => $email, 'name' => $name, 'password' => $password]);
$user = $this->userRepo->create($validator->validated());
$this->userRepo->attachSystemRole($user, 'admin');
$this->userRepo->downloadAndAssignUserAvatar($user);
$user->email_confirmed = true;
$user->save();
$this->info("Admin account with email \"{$user->email}\" successfully created!");
return SymfonyCommand::SUCCESS;
}
protected function snakeCaseOptions(): array
{
$returnOpts = [];
foreach ($this->options() as $key => $value) {
$returnOpts[str_replace('-', '_', $key)] = $value;
}
return $returnOpts;
}
}

View File

@@ -2,9 +2,10 @@
namespace BookStack\Console\Commands;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\SearchIndex;
use DB;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RegenerateSearch extends Command
{
@@ -22,6 +23,9 @@ class RegenerateSearch extends Command
*/
protected $description = 'Re-index all content for searching';
/**
* @var SearchIndex
*/
protected $searchIndex;
/**
@@ -45,8 +49,13 @@ class RegenerateSearch extends Command
DB::setDefaultConnection($this->option('database'));
}
$this->searchIndex->indexAllEntities();
$this->searchIndex->indexAllEntities(function (Entity $model, int $processed, int $total): void {
$this->info('Indexed ' . class_basename($model) . ' entries (' . $processed . '/' . $total . ')');
});
DB::setDefaultConnection($connection);
$this->comment('Search index regenerated');
$this->line('Search index regenerated!');
return static::SUCCESS;
}
}

View File

@@ -49,9 +49,10 @@ class ResetMfa extends Command
return 1;
}
/** @var User $user */
$field = $id ? 'id' : 'email';
$value = $id ?: $email;
/** @var User $user */
$user = User::query()
->where($field, '=', $value)
->first();

View File

@@ -4,6 +4,7 @@ namespace BookStack\Entities\Models;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -12,13 +13,18 @@ use Illuminate\Support\Collection;
/**
* Class Book.
*
* @property string $description
* @property int $image_id
* @property Image|null $cover
* @property string $description
* @property int $image_id
* @property Image|null $cover
* @property \Illuminate\Database\Eloquent\Collection $chapters
* @property \Illuminate\Database\Eloquent\Collection $pages
* @property \Illuminate\Database\Eloquent\Collection $directPages
*/
class Book extends Entity implements HasCoverImage
{
public $searchFactor = 2;
use HasFactory;
public $searchFactor = 1.2;
protected $fillable = ['name', 'description'];
protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at'];
@@ -73,53 +79,43 @@ class Book extends Entity implements HasCoverImage
/**
* Get all pages within this book.
*
* @return HasMany
*/
public function pages()
public function pages(): HasMany
{
return $this->hasMany(Page::class);
}
/**
* Get the direct child pages of this book.
*
* @return HasMany
*/
public function directPages()
public function directPages(): HasMany
{
return $this->pages()->where('chapter_id', '=', '0');
}
/**
* Get all chapters within this book.
*
* @return HasMany
*/
public function chapters()
public function chapters(): HasMany
{
return $this->hasMany(Chapter::class);
}
/**
* Get the shelves this book is contained within.
*
* @return BelongsToMany
*/
public function shelves()
public function shelves(): BelongsToMany
{
return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
}
/**
* Get the direct child items within this book.
*
* @return Collection
*/
public function getDirectChildren(): Collection
{
$pages = $this->directPages()->visible()->get();
$chapters = $this->chapters()->visible()->get();
$pages = $this->directPages()->scopes('visible')->get();
$chapters = $this->chapters()->scopes('visible')->get();
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
}

View File

@@ -3,14 +3,17 @@
namespace BookStack\Entities\Models;
use BookStack\Uploads\Image;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Bookshelf extends Entity implements HasCoverImage
{
use HasFactory;
protected $table = 'bookshelves';
public $searchFactor = 3;
public $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'image_id'];
@@ -34,7 +37,7 @@ class Bookshelf extends Entity implements HasCoverImage
*/
public function visibleBooks(): BelongsToMany
{
return $this->books()->visible();
return $this->books()->scopes('visible');
}
/**

View File

@@ -2,29 +2,31 @@
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
/**
* Class Chapter.
*
* @property Collection<Page> $pages
* @property mixed description
* @property string $description
*/
class Chapter extends BookChild
{
public $searchFactor = 1.3;
use HasFactory;
protected $fillable = ['name', 'description', 'priority', 'book_id'];
public $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'priority'];
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
/**
* Get the pages that this chapter contains.
*
* @param string $dir
*
* @return mixed
* @return HasMany<Page>
*/
public function pages($dir = 'ASC')
public function pages(string $dir = 'ASC'): HasMany
{
return $this->hasMany(Page::class)->orderBy('priority', $dir);
}
@@ -32,7 +34,7 @@ class Chapter extends BookChild
/**
* Get the url of this chapter.
*/
public function getUrl($path = ''): string
public function getUrl(string $path = ''): string
{
$parts = [
'books',
@@ -50,7 +52,8 @@ class Chapter extends BookChild
*/
public function getVisiblePages(): Collection
{
return $this->pages()->visible()
return $this->pages()
->scopes('visible')
->orderBy('draft', 'desc')
->orderBy('priority', 'asc')
->get();

View File

@@ -3,13 +3,14 @@
namespace BookStack\Entities\Models;
use BookStack\Auth\User;
use BookStack\Interfaces\Deletable;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property Model deletable
* @property Deletable $deletable
*/
class Deletion extends Model implements Loggable
{
@@ -22,7 +23,7 @@ class Deletion extends Model implements Loggable
}
/**
* The the user that performed the deletion.
* Get the user that performed the deletion.
*/
public function deleter(): BelongsTo
{
@@ -32,7 +33,7 @@ class Deletion extends Model implements Loggable
/**
* Create a new deletion record for the provided entity.
*/
public static function createForEntity(Entity $entity): Deletion
public static function createForEntity(Entity $entity): self
{
$record = (new self())->forceFill([
'deleted_by' => user()->id,
@@ -48,13 +49,17 @@ class Deletion extends Model implements Loggable
{
$deletable = $this->deletable()->first();
return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}";
if ($deletable instanceof Entity) {
return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}";
}
return "Deletion ({$this->id})";
}
/**
* Get a URL for this specific deletion.
*/
public function getUrl($path): string
public function getUrl(string $path = 'restore'): string
{
return url("/settings/recycle-bin/{$this->id}/" . ltrim($path, '/'));
}

View File

@@ -12,7 +12,9 @@ use BookStack\Auth\Permissions\JointPermission;
use BookStack\Entities\Tools\SearchIndex;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Facades\Permissions;
use BookStack\Interfaces\Deletable;
use BookStack\Interfaces\Favouritable;
use BookStack\Interfaces\Loggable;
use BookStack\Interfaces\Sluggable;
use BookStack\Interfaces\Viewable;
use BookStack\Model;
@@ -34,6 +36,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property string $slug
* @property Carbon $created_at
* @property Carbon $updated_at
* @property Carbon $deleted_at
* @property int $created_by
* @property int $updated_by
* @property bool $restricted
@@ -44,7 +47,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static Builder withLastView()
* @method static Builder withViewCount()
*/
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable
{
use SoftDeletes;
use HasCreatorAndUpdater;
@@ -106,7 +109,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
* Compares this entity to another given entity.
* Matches by comparing class and id.
*/
public function matches(Entity $entity): bool
public function matches(self $entity): bool
{
return [get_class($this), $this->id] === [get_class($entity), $entity->id];
}
@@ -114,17 +117,17 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/**
* Checks if the current entity matches or contains the given.
*/
public function matchesOrContains(Entity $entity): bool
public function matchesOrContains(self $entity): bool
{
if ($this->matches($entity)) {
return true;
}
if (($entity->isA('chapter') || $entity->isA('page')) && $this->isA('book')) {
if (($entity instanceof BookChild) && $this instanceof Book) {
return $entity->book_id === $this->id;
}
if ($entity->isA('page') && $this->isA('chapter')) {
if ($entity instanceof Page && $this instanceof Chapter) {
return $entity->chapter_id === $this->id;
}
@@ -210,6 +213,8 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/**
* Check if this instance or class is a certain type of entity.
* Examples of $type are 'page', 'book', 'chapter'.
*
* @deprecated Use instanceof instead.
*/
public static function isA(string $type): bool
{
@@ -238,20 +243,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
return mb_substr($this->name, 0, $length - 3) . '...';
}
/**
* Get the body text of this entity.
*/
public function getText(): string
{
return $this->{$this->textField} ?? '';
}
/**
* Get an excerpt of this entity's descriptive content to the specified length.
*/
public function getExcerpt(int $length = 100): string
{
$text = $this->getText();
$text = $this->{$this->textField} ?? '';
if (mb_strlen($text) > $length) {
$text = mb_substr($text, 0, $length - 3) . '...';
@@ -270,7 +267,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
* This is the "static" parent and does not include dynamic
* relations such as shelves to books.
*/
public function getParent(): ?Entity
public function getParent(): ?self
{
if ($this instanceof Page) {
return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first();
@@ -300,7 +297,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
}
/**
* @inheritdoc
* {@inheritdoc}
*/
public function refreshSlug(): string
{
@@ -310,7 +307,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
}
/**
* @inheritdoc
* {@inheritdoc}
*/
public function favourites(): MorphMany
{
@@ -326,4 +323,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
->where('user_id', '=', user()->id)
->exists();
}
/**
* {@inheritdoc}
*/
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}";
}
}

View File

@@ -3,12 +3,13 @@
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\PageContent;
use BookStack\Facades\Permissions;
use BookStack\Uploads\Attachment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Permissions;
/**
* Class Page.
@@ -25,10 +26,12 @@ use Permissions;
*/
class Page extends BookChild
{
use HasFactory;
public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at', 'priority'];
public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at', 'priority'];
protected $fillable = ['name', 'priority', 'markdown'];
protected $fillable = ['name', 'priority'];
public $textField = 'text';
@@ -61,10 +64,8 @@ class Page extends BookChild
/**
* Check if this page has a chapter.
*
* @return bool
*/
public function hasChapter()
public function hasChapter(): bool
{
return $this->chapter()->count() > 0;
}
@@ -103,7 +104,7 @@ class Page extends BookChild
/**
* Get the url of this page.
*/
public function getUrl($path = ''): string
public function getUrl(string $path = ''): string
{
$parts = [
'books',
@@ -129,7 +130,7 @@ class Page extends BookChild
/**
* Get this page for JSON display.
*/
public function forJsonDisplay(): Page
public function forJsonDisplay(): self
{
$refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']);
$refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));

View File

@@ -5,6 +5,7 @@ namespace BookStack\Entities\Models;
use BookStack\Auth\User;
use BookStack\Model;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Class PageRevision.
@@ -14,11 +15,14 @@ use Carbon\Carbon;
* @property string $book_slug
* @property int $created_by
* @property Carbon $created_at
* @property Carbon $updated_at
* @property string $type
* @property string $summary
* @property string $markdown
* @property string $html
* @property int $revision_number
* @property Page $page
* @property-read ?User $createdBy
*/
class PageRevision extends Model
{
@@ -26,47 +30,32 @@ class PageRevision extends Model
/**
* Get the user that created the page revision.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function createdBy()
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* Get the page this revision originates from.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function page()
public function page(): BelongsTo
{
return $this->belongsTo(Page::class);
}
/**
* Get the url for this revision.
*
* @param null|string $path
*
* @return string
*/
public function getUrl($path = null)
public function getUrl(string $path = ''): string
{
$url = $this->page->getUrl() . '/revisions/' . $this->id;
if ($path) {
return $url . '/' . trim($path, '/');
}
return $url;
return $this->page->getUrl('/revisions/' . $this->id . '/' . ltrim($path, '/'));
}
/**
* Get the previous revision for the same page if existing.
*
* @return \BookStack\Entities\PageRevision|null
*/
public function getPrevious()
public function getPrevious(): ?PageRevision
{
$id = static::newQuery()->where('page_id', '=', $this->page_id)
->where('id', '<', $this->id)
@@ -84,11 +73,9 @@ class PageRevision extends Model
* Included here to align with entities in similar use cases.
* (Yup, Bit of an awkward hack).
*
* @param $type
*
* @return bool
* @deprecated Use instanceof instead.
*/
public static function isA($type)
public static function isA(string $type): bool
{
return $type === 'revision';
}

View File

@@ -67,10 +67,12 @@ class BaseRepo
/**
* Update the given items' cover image, or clear it.
*
* @param Entity&HasCoverImage $entity
*
* @throws ImageUploadException
* @throws \Exception
*/
public function updateCoverImage(HasCoverImage $entity, ?UploadedFile $coverImage, bool $removeImage = false)
public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false)
{
if ($coverImage) {
$this->imageRepo->destroyImage($entity->cover);

View File

@@ -91,7 +91,7 @@ class BookRepo
{
$book = new Book();
$this->baseRepo->create($book, $input);
Activity::addForEntity($book, ActivityType::BOOK_CREATE);
Activity::add(ActivityType::BOOK_CREATE, $book);
return $book;
}
@@ -102,7 +102,7 @@ class BookRepo
public function update(Book $book, array $input): Book
{
$this->baseRepo->update($book, $input);
Activity::addForEntity($book, ActivityType::BOOK_UPDATE);
Activity::add(ActivityType::BOOK_UPDATE, $book);
return $book;
}
@@ -127,7 +127,7 @@ class BookRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyBook($book);
Activity::addForEntity($book, ActivityType::BOOK_DELETE);
Activity::add(ActivityType::BOOK_DELETE, $book);
$trashCan->autoClearOld();
}

View File

@@ -90,7 +90,7 @@ class BookshelfRepo
$shelf = new Bookshelf();
$this->baseRepo->create($shelf, $input);
$this->updateBooks($shelf, $bookIds);
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE);
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
return $shelf;
}
@@ -106,7 +106,7 @@ class BookshelfRepo
$this->updateBooks($shelf, $bookIds);
}
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE);
Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf);
return $shelf;
}
@@ -124,7 +124,8 @@ class BookshelfRepo
$syncData = Book::visible()
->whereIn('id', $bookIds)
->get(['id'])->pluck('id')->mapWithKeys(function ($bookId) use ($numericIDs) {
->pluck('id')
->mapWithKeys(function ($bookId) use ($numericIDs) {
return [$bookId => ['order' => $numericIDs->search($bookId)]];
});
@@ -176,7 +177,7 @@ class BookshelfRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyShelf($shelf);
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_DELETE);
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
$trashCan->autoClearOld();
}
}

View File

@@ -5,10 +5,12 @@ namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
use Exception;
@@ -49,7 +51,7 @@ class ChapterRepo
$chapter->book_id = $parentBook->id;
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
$this->baseRepo->create($chapter, $input);
Activity::addForEntity($chapter, ActivityType::CHAPTER_CREATE);
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
return $chapter;
}
@@ -60,7 +62,7 @@ class ChapterRepo
public function update(Chapter $chapter, array $input): Chapter
{
$this->baseRepo->update($chapter, $input);
Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
return $chapter;
}
@@ -74,7 +76,7 @@ class ChapterRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyChapter($chapter);
Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE);
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
$trashCan->autoClearOld();
}
@@ -84,27 +86,43 @@ class ChapterRepo
* 'book:<id>' (book:5).
*
* @throws MoveOperationException
* @throws PermissionsException
*/
public function move(Chapter $chapter, string $parentIdentifier): Book
{
$stringExploded = explode(':', $parentIdentifier);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
if ($entityType !== 'book') {
throw new MoveOperationException('Chapters can only be moved into books');
$parent = $this->findParentByIdentifier($parentIdentifier);
if (is_null($parent)) {
throw new MoveOperationException('Book to move chapter into not found');
}
/** @var Book $parent */
$parent = Book::visible()->where('id', '=', $entityId)->first();
if ($parent === null) {
throw new MoveOperationException('Book to move chapter into not found');
if (!userCan('chapter-create', $parent)) {
throw new PermissionsException('User does not have permission to create a chapter within the chosen book');
}
$chapter->changeBook($parent->id);
$chapter->rebuildPermissions();
Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE);
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
return $parent;
}
/**
* Find a page parent entity via an identifier string in the format:
* {type}:{id}
* Example: (book:5).
*
* @throws MoveOperationException
*/
public function findParentByIdentifier(string $identifier): ?Book
{
$stringExploded = explode(':', $identifier);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
if ($entityType !== 'book') {
throw new MoveOperationException('Chapters can only be in books');
}
return Book::visible()->where('id', '=', $entityId)->first();
}
}

View File

@@ -69,9 +69,10 @@ class PageRepo
*/
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
{
/** @var ?PageRevision $revision */
$revision = PageRevision::query()
->whereHas('page', function (Builder $query) {
$query->visible();
$query->scopes('visible');
})
->where('slug', '=', $pageSlug)
->where('type', '=', 'version')
@@ -80,7 +81,7 @@ class PageRepo
->with('page')
->first();
return $revision ? $revision->page : null;
return $revision->page ?? null;
}
/**
@@ -157,8 +158,8 @@ class PageRepo
*/
public function publishDraft(Page $draft, array $input): Page
{
$this->baseRepo->update($draft, $input);
$this->updateTemplateStatusAndContentFromInput($draft, $input);
$this->baseRepo->update($draft, $input);
$draft->draft = false;
$draft->revision_count = 1;
@@ -170,7 +171,7 @@ class PageRepo
$draft->indexForSearch();
$draft->refresh();
Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
Activity::add(ActivityType::PAGE_CREATE, $draft);
return $draft;
}
@@ -204,7 +205,7 @@ class PageRepo
$this->savePageRevision($page, $summary);
}
Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
Activity::add(ActivityType::PAGE_UPDATE, $page);
return $page;
}
@@ -252,9 +253,7 @@ class PageRepo
{
// If the page itself is a draft simply update that
if ($page->draft) {
if (isset($input['html'])) {
(new PageContent($page))->setNewHTML($input['html']);
}
$this->updateTemplateStatusAndContentFromInput($page, $input);
$page->fill($input);
$page->save();
@@ -282,7 +281,7 @@ class PageRepo
{
$trashCan = new TrashCan();
$trashCan->softDestroyPage($page);
Activity::addForEntity($page, ActivityType::PAGE_DELETE);
Activity::add(ActivityType::PAGE_DELETE, $page);
$trashCan->autoClearOld();
}
@@ -292,6 +291,8 @@ class PageRepo
public function restoreRevision(Page $page, int $revisionId): Page
{
$page->revision_count++;
/** @var PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
$page->fill($revision->toArray());
@@ -311,7 +312,7 @@ class PageRepo
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
$this->savePageRevision($page, $summary);
Activity::addForEntity($page, ActivityType::PAGE_RESTORE);
Activity::add(ActivityType::PAGE_RESTORE, $page);
return $page;
}
@@ -327,7 +328,7 @@ class PageRepo
public function move(Page $page, string $parentIdentifier): Entity
{
$parent = $this->findParentByIdentifier($parentIdentifier);
if ($parent === null) {
if (is_null($parent)) {
throw new MoveOperationException('Book or chapter to move page into not found');
}
@@ -336,59 +337,23 @@ class PageRepo
}
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
$page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id);
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
$page->changeBook($newBookId);
$page->rebuildPermissions();
Activity::addForEntity($page, ActivityType::PAGE_MOVE);
Activity::add(ActivityType::PAGE_MOVE, $page);
return $parent;
}
/**
* Copy an existing page in the system.
* Optionally providing a new parent via string identifier and a new name.
*
* @throws MoveOperationException
* @throws PermissionsException
*/
public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
{
$parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent();
if ($parent === null) {
throw new MoveOperationException('Book or chapter to move page into not found');
}
if (!userCan('page-create', $parent)) {
throw new PermissionsException('User does not have permission to create a page within the new parent');
}
$copyPage = $this->getNewDraftPage($parent);
$pageData = $page->getAttributes();
// Update name
if (!empty($newName)) {
$pageData['name'] = $newName;
}
// Copy tags from previous page if set
if ($page->tags) {
$pageData['tags'] = [];
foreach ($page->tags as $tag) {
$pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value];
}
}
return $this->publishDraft($copyPage, $pageData);
}
/**
* Find a page parent entity via a identifier string in the format:
* Find a page parent entity via an identifier string in the format:
* {type}:{id}
* Example: (book:5).
*
* @throws MoveOperationException
*/
protected function findParentByIdentifier(string $identifier): ?Entity
public function findParentByIdentifier(string $identifier): ?Entity
{
$stringExploded = explode(':', $identifier);
$entityType = $stringExploded[0];
@@ -408,7 +373,7 @@ class PageRepo
*/
protected function changeParent(Page $page, Entity $parent)
{
$book = ($parent instanceof Book) ? $parent : $parent->book;
$book = ($parent instanceof Chapter) ? $parent->book : $parent;
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0;
$page->save();
@@ -469,6 +434,7 @@ class PageRepo
{
$parent = $page->getParent();
if ($parent instanceof Chapter) {
/** @var ?Page $lastPage */
$lastPage = $parent->pages('desc')->first();
return $lastPage ? $lastPage->priority + 1 : 0;

View File

@@ -7,7 +7,6 @@ use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\SortOperationException;
use Illuminate\Support\Collection;
class BookContents
@@ -67,7 +66,7 @@ class BookContents
$all->each(function (Entity $entity) use ($renderPages) {
$entity->setRelation('book', $this->book);
if ($renderPages && $entity->isA('page')) {
if ($renderPages && $entity instanceof Page) {
$entity->html = (new PageContent($entity))->render();
}
});
@@ -107,111 +106,209 @@ class BookContents
}
/**
* Sort the books content using the given map.
* The map is a single-dimension collection of objects in the following format:
* {
* +"id": "294" (ID of item)
* +"sort": 1 (Sort order index)
* +"parentChapter": false (ID of parent chapter, as string, or false)
* +"type": "page" (Entity type of item)
* +"book": "1" (Id of book to place item in)
* }.
*
* Sort the books content using the given sort map.
* Returns a list of books that were involved in the operation.
*
* @throws SortOperationException
* @returns Book[]
*/
public function sortUsingMap(Collection $sortMap): Collection
public function sortUsingMap(BookSortMap $sortMap): array
{
// Load models into map
$this->loadModelsIntoSortMap($sortMap);
$booksInvolved = $this->getBooksInvolvedInSort($sortMap);
$modelMap = $this->loadModelsFromSortMap($sortMap);
// Sort our changes from our map to be chapters first
// Since they need to be process to ensure book alignment for child page changes.
$sortMapItems = $sortMap->all();
usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
$aScore = $itemA->type === 'page' ? 2 : 1;
$bScore = $itemB->type === 'page' ? 2 : 1;
return $aScore - $bScore;
});
// Perform the sort
$sortMap->each(function ($mapItem) {
$this->applySortUpdates($mapItem);
});
foreach ($sortMapItems as $item) {
$this->applySortUpdates($item, $modelMap);
}
// Update permissions and activity.
$booksInvolved->each(function (Book $book) {
/** @var Book[] $booksInvolved */
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
return strpos($key, 'book:') === 0;
}, ARRAY_FILTER_USE_KEY));
// Update permissions of books involved
foreach ($booksInvolved as $book) {
$book->rebuildPermissions();
});
}
return $booksInvolved;
}
/**
* Using the given sort map item, detect changes for the related model
* and update it if required.
* and update it if required. Changes where permissions are lacking will
* be skipped and not throw an error.
*
* @param array<string, Entity> $modelMap
*/
protected function applySortUpdates(\stdClass $sortMapItem)
protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
{
/** @var BookChild $model */
$model = $sortMapItem->model;
$model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
if (!$model) {
return;
}
$priorityChanged = intval($model->priority) !== intval($sortMapItem->sort);
$bookChanged = intval($model->book_id) !== intval($sortMapItem->book);
$chapterChanged = ($sortMapItem->type === 'page') && intval($model->chapter_id) !== $sortMapItem->parentChapter;
$priorityChanged = $model->priority !== $sortMapItem->sort;
$bookChanged = $model->book_id !== $sortMapItem->parentBookId;
$chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
// Stop if there's no change
if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
return;
}
$currentParentKey = 'book:' . $model->book_id;
if ($model instanceof Page && $model->chapter_id) {
$currentParentKey = 'chapter:' . $model->chapter_id;
}
$currentParent = $modelMap[$currentParentKey] ?? null;
/** @var Book $newBook */
$newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
/** @var ?Chapter $newChapter */
$newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
return;
}
// Action the required changes
if ($bookChanged) {
$model->changeBook($sortMapItem->book);
$model->changeBook($newBook->id);
}
if ($chapterChanged) {
$model->chapter_id = intval($sortMapItem->parentChapter);
$model->save();
$model->chapter_id = $newChapter->id ?? 0;
}
if ($priorityChanged) {
$model->priority = intval($sortMapItem->sort);
$model->priority = $sortMapItem->sort;
}
if ($chapterChanged || $priorityChanged) {
$model->save();
}
}
/**
* Check if the current user has permissions to apply the given sorting change.
* Is quite complex since items can gain a different parent change. Acts as a:
* - Update of old parent element (Change of content/order).
* - Update of sorted/moved element.
* - Deletion of element (Relative to parent upon move).
* - Creation of element within parent (Upon move to new parent).
*/
protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
{
// Stop if we can't see the current parent or new book.
if (!$currentParent || !$newBook) {
return false;
}
$hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
if ($model instanceof Chapter) {
$hasPermission = userCan('book-update', $currentParent)
&& userCan('book-update', $newBook)
&& userCan('chapter-update', $model)
&& (!$hasNewParent || userCan('chapter-create', $newBook))
&& (!$hasNewParent || userCan('chapter-delete', $model));
if (!$hasPermission) {
return false;
}
}
if ($model instanceof Page) {
$parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasCurrentParentPermission = userCan($parentPermission, $currentParent);
// This needs to check if there was an intended chapter location in the original sort map
// rather than inferring from the $newChapter since that variable may be null
// due to other reasons (Visibility).
$newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
if (!$newParent) {
return false;
}
$hasPageEditPermission = userCan('page-update', $model);
$newParentInRightLocation = ($newParent instanceof Book || $newParent->book_id === $newBook->id);
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasNewParentPermission = userCan($newParentPermission, $newParent);
$hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
$hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
$hasPermission = $hasCurrentParentPermission
&& $newParentInRightLocation
&& $hasNewParentPermission
&& $hasPageEditPermission
&& $hasDeletePermissionIfMoving
&& $hasCreatePermissionIfMoving;
if (!$hasPermission) {
return false;
}
}
return true;
}
/**
* Load models from the database into the given sort map.
*/
protected function loadModelsIntoSortMap(Collection $sortMap): void
{
$keyMap = $sortMap->keyBy(function (\stdClass $sortMapItem) {
return $sortMapItem->type . ':' . $sortMapItem->id;
});
$pageIds = $sortMap->where('type', '=', 'page')->pluck('id');
$chapterIds = $sortMap->where('type', '=', 'chapter')->pluck('id');
$pages = Page::visible()->whereIn('id', $pageIds)->get();
$chapters = Chapter::visible()->whereIn('id', $chapterIds)->get();
foreach ($pages as $page) {
$sortItem = $keyMap->get('page:' . $page->id);
$sortItem->model = $page;
}
foreach ($chapters as $chapter) {
$sortItem = $keyMap->get('chapter:' . $chapter->id);
$sortItem->model = $chapter;
}
}
/**
* Get the books involved in a sort.
* The given sort map should have its models loaded first.
*
* @throws SortOperationException
* @return array<string, Entity>
*/
protected function getBooksInvolvedInSort(Collection $sortMap): Collection
protected function loadModelsFromSortMap(BookSortMap $sortMap): array
{
$bookIdsInvolved = collect([$this->book->id]);
$bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('book'));
$bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('model.book_id'));
$bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
$modelMap = [];
$ids = [
'chapter' => [],
'page' => [],
'book' => [],
];
$books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get();
if (count($books) !== count($bookIdsInvolved)) {
throw new SortOperationException('Could not find all books requested in sort operation');
foreach ($sortMap->all() as $sortMapItem) {
$ids[$sortMapItem->type][] = $sortMapItem->id;
$ids['book'][] = $sortMapItem->parentBookId;
if ($sortMapItem->parentChapterId) {
$ids['chapter'][] = $sortMapItem->parentChapterId;
}
}
return $books;
$pages = Page::visible()->whereIn('id', array_unique($ids['page']))->get(Page::$listAttributes);
/** @var Page $page */
foreach ($pages as $page) {
$modelMap['page:' . $page->id] = $page;
$ids['book'][] = $page->book_id;
if ($page->chapter_id) {
$ids['chapter'][] = $page->chapter_id;
}
}
$chapters = Chapter::visible()->whereIn('id', array_unique($ids['chapter']))->get();
/** @var Chapter $chapter */
foreach ($chapters as $chapter) {
$modelMap['chapter:' . $chapter->id] = $chapter;
$ids['book'][] = $chapter->book_id;
}
$books = Book::visible()->whereIn('id', array_unique($ids['book']))->get();
/** @var Book $book */
foreach ($books as $book) {
$modelMap['book:' . $book->id] = $book;
}
return $modelMap;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace BookStack\Entities\Tools;
class BookSortMap
{
/**
* @var BookSortMapItem[]
*/
protected $mapData = [];
public function addItem(BookSortMapItem $mapItem): void
{
$this->mapData[] = $mapItem;
}
/**
* @return BookSortMapItem[]
*/
public function all(): array
{
return $this->mapData;
}
public static function fromJson(string $json): self
{
$map = new BookSortMap();
$mapData = json_decode($json);
foreach ($mapData as $mapDataItem) {
$item = new BookSortMapItem(
intval($mapDataItem->id),
intval($mapDataItem->sort),
$mapDataItem->parentChapter ? intval($mapDataItem->parentChapter) : null,
$mapDataItem->type,
intval($mapDataItem->book)
);
$map->addItem($item);
}
return $map;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace BookStack\Entities\Tools;
class BookSortMapItem
{
/**
* @var int
*/
public $id;
/**
* @var int
*/
public $sort;
/**
* @var ?int
*/
public $parentChapterId;
/**
* @var string
*/
public $type;
/**
* @var int
*/
public $parentBookId;
public function __construct(int $id, int $sort, ?int $parentChapterId, string $type, int $parentBookId)
{
$this->id = $id;
$this->sort = $sort;
$this->parentChapterId = $parentChapterId;
$this->type = $type;
$this->parentBookId = $parentBookId;
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Actions\Tag;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
use Illuminate\Http\UploadedFile;
class Cloner
{
/**
* @var PageRepo
*/
protected $pageRepo;
/**
* @var ChapterRepo
*/
protected $chapterRepo;
/**
* @var BookRepo
*/
protected $bookRepo;
/**
* @var ImageService
*/
protected $imageService;
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
{
$this->pageRepo = $pageRepo;
$this->chapterRepo = $chapterRepo;
$this->bookRepo = $bookRepo;
$this->imageService = $imageService;
}
/**
* Clone the given page into the given parent using the provided name.
*/
public function clonePage(Page $original, Entity $parent, string $newName): Page
{
$copyPage = $this->pageRepo->getNewDraftPage($parent);
$pageData = $original->getAttributes();
// Update name & tags
$pageData['name'] = $newName;
$pageData['tags'] = $this->entityTagsToInputArray($original);
return $this->pageRepo->publishDraft($copyPage, $pageData);
}
/**
* Clone the given page into the given parent using the provided name.
* Clones all child pages.
*/
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
{
$chapterDetails = $original->getAttributes();
$chapterDetails['name'] = $newName;
$chapterDetails['tags'] = $this->entityTagsToInputArray($original);
$copyChapter = $this->chapterRepo->create($chapterDetails, $parent);
if (userCan('page-create', $copyChapter)) {
/** @var Page $page */
foreach ($original->getVisiblePages() as $page) {
$this->clonePage($page, $copyChapter, $page->name);
}
}
return $copyChapter;
}
/**
* Clone the given book.
* Clones all child chapters & pages.
*/
public function cloneBook(Book $original, string $newName): Book
{
$bookDetails = $original->getAttributes();
$bookDetails['name'] = $newName;
$bookDetails['tags'] = $this->entityTagsToInputArray($original);
$copyBook = $this->bookRepo->create($bookDetails);
$directChildren = $original->getDirectChildren();
foreach ($directChildren as $child) {
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
$this->cloneChapter($child, $copyBook, $child->name);
}
if ($child instanceof Page && !$child->draft && userCan('page-create', $copyBook)) {
$this->clonePage($child, $copyBook, $child->name);
}
}
if ($original->cover) {
try {
$tmpImgFile = tmpfile();
$uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile);
$this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false);
} catch (\Exception $exception) {
}
}
return $copyBook;
}
/**
* Convert an image instance to an UploadedFile instance to mimic
* a file being uploaded.
*/
protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile
{
$imgData = $this->imageService->getImageData($image);
$tmpImgFilePath = stream_get_meta_data($tmpFile)['uri'];
file_put_contents($tmpImgFilePath, $imgData);
return new UploadedFile($tmpImgFilePath, basename($image->path));
}
/**
* Convert the tags on the given entity to the raw format
* that's used for incoming request data.
*/
protected function entityTagsToInputArray(Entity $entity): array
{
$tags = [];
/** @var Tag $tag */
foreach ($entity->tags as $tag) {
$tags[] = ['name' => $tag->name, 'value' => $tag->value];
}
return $tags;
}
}

View File

@@ -7,21 +7,24 @@ use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Uploads\ImageService;
use DomPDF;
use DOMDocument;
use DOMElement;
use DOMXPath;
use Exception;
use SnappyPDF;
use Throwable;
class ExportFormatter
{
protected $imageService;
protected $pdfGenerator;
/**
* ExportService constructor.
*/
public function __construct(ImageService $imageService)
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator)
{
$this->imageService = $imageService;
$this->pdfGenerator = $pdfGenerator;
}
/**
@@ -89,6 +92,7 @@ class ExportFormatter
$html = view('pages.export', [
'page' => $page,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
])->render();
return $this->htmlToPdf($html);
@@ -110,6 +114,7 @@ class ExportFormatter
'chapter' => $chapter,
'pages' => $pages,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
])->render();
return $this->htmlToPdf($html);
@@ -127,6 +132,7 @@ class ExportFormatter
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
])->render();
return $this->htmlToPdf($html);
@@ -139,16 +145,40 @@ class ExportFormatter
*/
protected function htmlToPdf(string $html): string
{
$containedHtml = $this->containHtml($html);
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
if ($useWKHTML) {
$pdf = SnappyPDF::loadHTML($containedHtml);
$pdf->setOption('print-media-type', true);
} else {
$pdf = DomPDF::loadHTML($containedHtml);
$html = $this->containHtml($html);
$html = $this->replaceIframesWithLinks($html);
return $this->pdfGenerator->fromHtml($html);
}
/**
* Within the given HTML content, replace any iframe elements
* with anchor links within paragraph blocks.
*/
protected function replaceIframesWithLinks(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);
$iframes = $xPath->query('//iframe');
/** @var DOMElement $iframe */
foreach ($iframes as $iframe) {
$link = $iframe->getAttribute('src');
if (strpos($link, '//') === 0) {
$link = 'https:' . $link;
}
$anchor = $doc->createElement('a', $link);
$anchor->setAttribute('href', $link);
$paragraph = $doc->createElement('p');
$paragraph->appendChild($anchor);
$iframe->parentNode->replaceChild($paragraph, $iframe);
}
return $pdf->output();
return $doc->saveHTML();
}
/**

View File

@@ -64,7 +64,7 @@ class NextPreviousContentLocator
/** @var Entity $item */
foreach ($bookTree->all() as $item) {
$flatOrdered->push($item);
$childPages = $item->visible_pages ?? [];
$childPages = $item->getAttribute('visible_pages') ?? [];
$flatOrdered = $flatOrdered->concat($childPages);
}

View File

@@ -9,8 +9,11 @@ use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService;
use BookStack\Util\HtmlContentFilter;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMNodeList;
use DOMXPath;
use Illuminate\Support\Str;
@@ -37,7 +40,7 @@ class PageContent
*/
public function setNewHTML(string $html)
{
$html = $this->extractBase64Images($this->page, $html);
$html = $this->extractBase64ImagesFromHtml($html);
$this->page->html = $this->formatHtml($html);
$this->page->text = $this->toPlainText();
$this->page->markdown = '';
@@ -48,6 +51,7 @@ class PageContent
*/
public function setNewMarkdown(string $markdown)
{
$markdown = $this->extractBase64ImagesFromMarkdown($markdown);
$this->page->markdown = $markdown;
$html = $this->markdownToHtml($markdown);
$this->page->html = $this->formatHtml($html);
@@ -74,7 +78,7 @@ class PageContent
/**
* Convert all base64 image data to saved images.
*/
public function extractBase64Images(Page $page, string $htmlText): string
protected function extractBase64ImagesFromHtml(string $htmlText): string
{
if (empty($htmlText) || strpos($htmlText, 'data:image') === false) {
return $htmlText;
@@ -85,31 +89,13 @@ class PageContent
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
$xPath = new DOMXPath($doc);
$imageRepo = app()->make(ImageRepo::class);
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
// Get all img elements with image data blobs
$imageNodes = $xPath->query('//img[contains(@src, \'data:image\')]');
foreach ($imageNodes as $imageNode) {
$imageSrc = $imageNode->getAttribute('src');
[$dataDefinition, $base64ImageData] = explode(',', $imageSrc, 2);
$extension = strtolower(preg_split('/[\/;]/', $dataDefinition)[1] ?? 'png');
// Validate extension
if (!in_array($extension, $allowedExtensions)) {
$imageNode->setAttribute('src', '');
continue;
}
// Save image from data with a random name
$imageName = 'embedded-image-' . Str::random(8) . '.' . $extension;
try {
$image = $imageRepo->saveNewFromData($imageName, base64_decode($base64ImageData), 'gallery', $page->id);
$imageNode->setAttribute('src', $image->url);
} catch (ImageUploadException $exception) {
$imageNode->setAttribute('src', '');
}
$newUrl = $this->base64ImageUriToUploadedImageUrl($imageSrc);
$imageNode->setAttribute('src', $newUrl);
}
// Generate inner html as a string
@@ -121,6 +107,90 @@ class PageContent
return $html;
}
/**
* 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 = [];
$contentLength = strlen($markdown);
$replacements = [];
preg_match_all('/!\[.*?]\(.*?(data:image\/.{1,6};base64,)/', $markdown, $matches, PREG_OFFSET_CAPTURE);
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;
}
/**
* Parse the given base64 image URI and return the URL to the created image instance.
* Returns an empty string if the parsed URI is invalid or causes an error upon upload.
*/
protected function base64ImageUriToUploadedImageUrl(string $uri): string
{
$imageRepo = app()->make(ImageRepo::class);
$imageInfo = $this->parseBase64ImageUri($uri);
// Validate extension and content
if (empty($imageInfo['data']) || !ImageService::isExtensionSupported($imageInfo['extension'])) {
return '';
}
// Validate that the content is not over our upload limit
$uploadLimitBytes = (config('app.upload_limit') * 1000000);
if (strlen($imageInfo['data']) > $uploadLimitBytes) {
return '';
}
// Save image from data with a random name
$imageName = 'embedded-image-' . Str::random(8) . '.' . $imageInfo['extension'];
try {
$image = $imageRepo->saveNewFromData($imageName, $imageInfo['data'], 'gallery', $this->page->id);
} catch (ImageUploadException $exception) {
return '';
}
return $image->url;
}
/**
* Parse a base64 image URI into the data and extension.
*
* @return array{extension: string, data: string}
*/
protected function parseBase64ImageUri(string $uri): array
{
[$dataDefinition, $base64ImageData] = explode(',', $uri, 2);
$extension = strtolower(preg_split('/[\/;]/', $dataDefinition)[1] ?? '');
return [
'extension' => $extension,
'data' => base64_decode($base64ImageData) ?: '',
];
}
/**
* Formats a page's html to be tagged correctly within the system.
*/
@@ -145,6 +215,15 @@ class PageContent
}
}
// Set ids on nested header nodes
$nestedHeaders = $xPath->query('//body//*//h1|//body//*//h2|//body//*//h3|//body//*//h4|//body//*//h5|//body//*//h6');
foreach ($nestedHeaders as $nestedHeader) {
[$oldId, $newId] = $this->setUniqueId($nestedHeader, $idMap);
if ($newId && $newId !== $oldId) {
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
}
}
// Ensure no duplicate ids within child items
$idElems = $xPath->query('//body//*//*[@id]');
foreach ($idElems as $domElem) {
@@ -180,9 +259,9 @@ class PageContent
* A map for existing ID's should be passed in to check for current existence.
* Returns a pair of strings in the format [old_id, new_id].
*/
protected function setUniqueId(\DOMNode $element, array &$idMap): array
protected function setUniqueId(DOMNode $element, array &$idMap): array
{
if (get_class($element) !== 'DOMElement') {
if (!$element instanceof DOMElement) {
return ['', ''];
}
@@ -194,7 +273,7 @@ class PageContent
return [$existingId, $existingId];
}
// Create an unique id for the element
// Create a unique id for the element
// Uses the content as a basis to ensure output is the same every time
// the same content is passed through.
$contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
@@ -264,7 +343,7 @@ class PageContent
*/
protected function headerNodesToLevelList(DOMNodeList $nodeList): array
{
$tree = collect($nodeList)->map(function ($header) {
$tree = collect($nodeList)->map(function (DOMElement $header) {
$text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
$text = mb_substr($text, 0, 100);
@@ -316,6 +395,7 @@ class PageContent
}
// Find page and skip this if page not found
/** @var ?Page $matchedPage */
$matchedPage = Page::visible()->find($pageId);
if ($matchedPage === null) {
$html = str_replace($fullMatch, '', $html);
@@ -341,7 +421,7 @@ class PageContent
*/
protected function fetchSectionOfPage(Page $page, string $sectionId): string
{
$topLevelTags = ['table', 'ul', 'ol'];
$topLevelTags = ['table', 'ul', 'ol', 'pre'];
$doc = $this->loadDocumentFromHtml($page->html);
// Search included content for the id given and blank out if not exists.

View File

@@ -21,8 +21,6 @@ class PageEditActivity
/**
* Check if there's active editing being performed on this page.
*
* @return bool
*/
public function hasActiveEditing(): bool
{
@@ -37,18 +35,50 @@ class PageEditActivity
$pageDraftEdits = $this->activePageEditingQuery(60)->get();
$count = $pageDraftEdits->count();
$userMessage = $count > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $count]) : trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
$userMessage = trans('entities.pages_draft_edit_active.start_a', ['count' => $count]);
if ($count === 1) {
/** @var PageRevision $firstDraft */
$firstDraft = $pageDraftEdits->first();
$userMessage = trans('entities.pages_draft_edit_active.start_b', ['userName' => $firstDraft->createdBy->name ?? '']);
}
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]);
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
}
/**
* Get any editor clash warning messages to show for the given draft revision.
*
* @param PageRevision|Page $draft
*
* @return string[]
*/
public function getWarningMessagesForDraft($draft): array
{
$warnings = [];
if ($this->hasActiveEditing()) {
$warnings[] = $this->activeEditingMessage();
}
if ($draft instanceof PageRevision && $this->hasPageBeenUpdatedSinceDraftCreated($draft)) {
$warnings[] = trans('entities.pages_draft_page_changed_since_creation');
}
return $warnings;
}
/**
* Check if the page has been updated since the draft has been saved.
*/
protected function hasPageBeenUpdatedSinceDraftCreated(PageRevision $draft): bool
{
return $draft->page->updated_at->timestamp > $draft->created_at->timestamp;
}
/**
* Get the message to show when the user will be editing one of their drafts.
*
* @param PageRevision $draft
*
* @return string
*/
public function getEditingActiveDraftMessage(PageRevision $draft): string
{

View File

@@ -0,0 +1,38 @@
<?php
namespace BookStack\Entities\Tools;
use Barryvdh\DomPDF\Facade as DomPDF;
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
{
if ($this->getActiveEngine() === self::ENGINE_WKHTML) {
$pdf = SnappyPDF::loadHTML($html);
$pdf->setOption('print-media-type', true);
} else {
$pdf = DomPDF::loadHTML($html);
}
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

@@ -35,7 +35,7 @@ class PermissionsUpdater
$entity->save();
$entity->rebuildPermissions();
Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE);
Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
}
/**

View File

@@ -2,26 +2,32 @@
namespace BookStack\Entities\Tools;
use BookStack\Actions\Tag;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\SearchTerm;
use DOMDocument;
use DOMNode;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class SearchIndex
{
/**
* @var SearchTerm
* A list of delimiter characters used to break-up parsed content into terms for indexing.
*
* @var string
*/
protected $searchTerm;
public static $delimiters = " \n\t.,!?:;()[]{}<>`'\"";
/**
* @var EntityProvider
*/
protected $entityProvider;
public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider)
public function __construct(EntityProvider $entityProvider)
{
$this->searchTerm = $searchTerm;
$this->entityProvider = $entityProvider;
}
@@ -31,14 +37,8 @@ class SearchIndex
public function indexEntity(Entity $entity)
{
$this->deleteEntityTerms($entity);
$nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
$bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
$terms = array_merge($nameTerms, $bodyTerms);
foreach ($terms as $index => $term) {
$terms[$index]['entity_type'] = $entity->getMorphClass();
$terms[$index]['entity_id'] = $entity->id;
}
$this->searchTerm->newQuery()->insert($terms);
$terms = $this->entityToTermDataArray($entity);
SearchTerm::query()->insert($terms);
}
/**
@@ -46,40 +46,56 @@ class SearchIndex
*
* @param Entity[] $entities
*/
protected function indexEntities(array $entities)
public function indexEntities(array $entities)
{
$terms = [];
foreach ($entities as $entity) {
$nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
$bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
foreach (array_merge($nameTerms, $bodyTerms) as $term) {
$term['entity_id'] = $entity->id;
$term['entity_type'] = $entity->getMorphClass();
$terms[] = $term;
}
$entityTerms = $this->entityToTermDataArray($entity);
array_push($terms, ...$entityTerms);
}
$chunkedTerms = array_chunk($terms, 500);
foreach ($chunkedTerms as $termChunk) {
$this->searchTerm->newQuery()->insert($termChunk);
SearchTerm::query()->insert($termChunk);
}
}
/**
* Delete and re-index the terms for all entities in the system.
* Can take a callback which is used for reporting progress.
* Callback receives three arguments:
* - An instance of the model being processed
* - The number that have been processed so far.
* - The total number of that model to be processed.
*
* @param callable(Entity, int, int):void|null $progressCallback
*/
public function indexAllEntities()
public function indexAllEntities(?callable $progressCallback = null)
{
$this->searchTerm->newQuery()->truncate();
SearchTerm::query()->truncate();
foreach ($this->entityProvider->all() as $entityModel) {
$selectFields = ['id', 'name', $entityModel->textField];
$indexContentField = $entityModel instanceof Page ? 'html' : 'description';
$selectFields = ['id', 'name', $indexContentField];
/** @var Builder<Entity> $query */
$query = $entityModel->newQuery();
$total = $query->withTrashed()->count();
$chunkSize = 250;
$processed = 0;
$chunkCallback = function (Collection $entities) use ($progressCallback, &$processed, $total, $chunkSize, $entityModel) {
$this->indexEntities($entities->all());
$processed = min($processed + $chunkSize, $total);
if (is_callable($progressCallback)) {
$progressCallback($entityModel, $processed, $total);
}
};
$entityModel->newQuery()
->withTrashed()
->select($selectFields)
->chunk(1000, function (Collection $entities) {
$this->indexEntities($entities->all());
});
->with(['tags:id,name,value,entity_id,entity_type'])
->chunk($chunkSize, $chunkCallback);
}
}
@@ -92,12 +108,97 @@ class SearchIndex
}
/**
* Create a scored term array from the given text.
* Create a scored term array from the given text, where the keys are the terms
* and the values are their scores.
*
* @returns array<string, int>
*/
protected function generateTermArrayFromText(string $text, int $scoreAdjustment = 1): array
protected function generateTermScoreMapFromText(string $text, int $scoreAdjustment = 1): array
{
$termMap = $this->textToTermCountMap($text);
foreach ($termMap as $term => $count) {
$termMap[$term] = $count * $scoreAdjustment;
}
return $termMap;
}
/**
* Create a scored term array from the given HTML, where the keys are the terms
* and the values are their scores.
*
* @returns array<string, int>
*/
protected function generateTermScoreMapFromHtml(string $html): array
{
if (empty($html)) {
return [];
}
$scoresByTerm = [];
$elementScoreAdjustmentMap = [
'h1' => 10,
'h2' => 5,
'h3' => 4,
'h4' => 3,
'h5' => 2,
'h6' => 1.5,
];
$html = '<body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
/** @var DOMNode $child */
foreach ($topElems as $child) {
$nodeName = $child->nodeName;
$termCounts = $this->textToTermCountMap(trim($child->textContent));
foreach ($termCounts as $term => $count) {
$scoreChange = $count * ($elementScoreAdjustmentMap[$nodeName] ?? 1);
$scoresByTerm[$term] = ($scoresByTerm[$term] ?? 0) + $scoreChange;
}
}
return $scoresByTerm;
}
/**
* Create a scored term map from the given set of entity tags.
*
* @param Tag[] $tags
*
* @returns array<string, int>
*/
protected function generateTermScoreMapFromTags(array $tags): array
{
$scoreMap = [];
$names = [];
$values = [];
foreach ($tags as $tag) {
$names[] = $tag->name;
$values[] = $tag->value;
}
$nameMap = $this->generateTermScoreMapFromText(implode(' ', $names), 3);
$valueMap = $this->generateTermScoreMapFromText(implode(' ', $values), 5);
return $this->mergeTermScoreMaps($nameMap, $valueMap);
}
/**
* For the given text, return an array where the keys are the unique term words
* and the values are the frequency of that term.
*
* @returns array<string, int>
*/
protected function textToTermCountMap(string $text): array
{
$tokenMap = []; // {TextToken => OccurrenceCount}
$splitChars = " \n\t.,!?:;()[]{}<>`'\"";
$splitChars = static::$delimiters;
$token = strtok($text, $splitChars);
while ($token !== false) {
@@ -108,14 +209,61 @@ class SearchIndex
$token = strtok($splitChars);
}
$terms = [];
foreach ($tokenMap as $token => $count) {
$terms[] = [
'term' => $token,
'score' => $count * $scoreAdjustment,
return $tokenMap;
}
/**
* For the given entity, Generate an array of term data details.
* Is the raw term data, not instances of SearchTerm models.
*
* @returns array{term: string, score: float, entity_id: int, entity_type: string}[]
*/
protected function entityToTermDataArray(Entity $entity): array
{
$nameTermsMap = $this->generateTermScoreMapFromText($entity->name, 40 * $entity->searchFactor);
$tagTermsMap = $this->generateTermScoreMapFromTags($entity->tags->all());
if ($entity instanceof Page) {
$bodyTermsMap = $this->generateTermScoreMapFromHtml($entity->html);
} else {
$bodyTermsMap = $this->generateTermScoreMapFromText($entity->getAttribute('description') ?? '', $entity->searchFactor);
}
$mergedScoreMap = $this->mergeTermScoreMaps($nameTermsMap, $bodyTermsMap, $tagTermsMap);
$dataArray = [];
$entityId = $entity->id;
$entityType = $entity->getMorphClass();
foreach ($mergedScoreMap as $term => $score) {
$dataArray[] = [
'term' => $term,
'score' => $score,
'entity_type' => $entityType,
'entity_id' => $entityId,
];
}
return $terms;
return $dataArray;
}
/**
* For the given term data arrays, Merge their contents by term
* while combining any scores.
*
* @param array<string, int>[] ...$scoreMaps
*
* @returns array<string, int>
*/
protected function mergeTermScoreMaps(...$scoreMaps): array
{
$mergedMap = [];
foreach ($scoreMaps as $scoreMap) {
foreach ($scoreMap as $term => $score) {
$mergedMap[$term] = ($mergedMap[$term] ?? 0) + $score;
}
}
return $mergedMap;
}
}

View File

@@ -29,10 +29,10 @@ class SearchOptions
/**
* Create a new instance from a search string.
*/
public static function fromString(string $search): SearchOptions
public static function fromString(string $search): self
{
$decoded = static::decode($search);
$instance = new static();
$instance = new SearchOptions();
foreach ($decoded as $type => $value) {
$instance->$type = $value;
}
@@ -45,7 +45,7 @@ class SearchOptions
* Will look for a classic string term and use that
* Otherwise we'll use the details from an advanced search form.
*/
public static function fromRequest(Request $request): SearchOptions
public static function fromRequest(Request $request): self
{
if (!$request->has('search') && !$request->has('term')) {
return static::fromString('');
@@ -55,17 +55,24 @@ class SearchOptions
return static::fromString($request->get('term'));
}
$instance = new static();
$instance = new SearchOptions();
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
$instance->searches = explode(' ', $inputs['search'] ?? []);
$instance->exacts = array_filter($inputs['exact'] ?? []);
$parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
$instance->searches = $parsedStandardTerms['terms'];
$instance->exacts = $parsedStandardTerms['exacts'];
array_push($instance->exacts, ...array_filter($inputs['exact'] ?? []));
$instance->tags = array_filter($inputs['tags'] ?? []);
foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
if (empty($filterVal)) {
continue;
}
$instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal;
}
if (isset($inputs['types']) && count($inputs['types']) < 4) {
$instance->filters['type'] = implode('|', $inputs['types']);
}
@@ -102,11 +109,9 @@ class SearchOptions
}
// Parse standard terms
foreach (explode(' ', trim($searchString)) as $searchTerm) {
if ($searchTerm !== '') {
$terms['searches'][] = $searchTerm;
}
}
$parsedStandardTerms = static::parseStandardTermString($searchString);
array_push($terms['searches'], ...$parsedStandardTerms['terms']);
array_push($terms['exacts'], ...$parsedStandardTerms['exacts']);
// Split filter values out
$splitFilters = [];
@@ -119,6 +124,33 @@ class SearchOptions
return $terms;
}
/**
* Parse a standard search term string into individual search terms and
* extract any exact terms searches to be made.
*
* @return array{terms: array<string>, exacts: array<string>}
*/
protected static function parseStandardTermString(string $termString): array
{
$terms = explode(' ', $termString);
$indexDelimiters = SearchIndex::$delimiters;
$parsed = [
'terms' => [],
'exacts' => [],
];
foreach ($terms as $searchTerm) {
if ($searchTerm === '') {
continue;
}
$parsedList = (strpbrk($searchTerm, $indexDelimiters) === false) ? 'terms' : 'exacts';
$parsed[$parsedList][] = $searchTerm;
}
return $parsed;
}
/**
* Encode this instance to a search string.
*/

View File

@@ -0,0 +1,236 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Actions\Tag;
use BookStack\Entities\Models\Entity;
use Illuminate\Support\HtmlString;
class SearchResultsFormatter
{
/**
* For the given array of entities, Prepare the models to be shown in search result
* output. This sets a series of additional attributes.
*
* @param Entity[] $results
*/
public function format(array $results, SearchOptions $options): void
{
foreach ($results as $result) {
$this->setSearchPreview($result, $options);
}
}
/**
* Update the given entity model to set attributes used for previews of the item
* primarily within search result lists.
*/
protected function setSearchPreview(Entity $entity, SearchOptions $options)
{
$textProperty = $entity->textField;
$textContent = $entity->$textProperty;
$terms = array_merge($options->exacts, $options->searches);
$originalContentByNewAttribute = [
'preview_name' => $entity->name,
'preview_content' => $textContent,
];
foreach ($originalContentByNewAttribute as $attributeName => $content) {
$targetLength = ($attributeName === 'preview_name') ? 0 : 260;
$matchRefs = $this->getMatchPositions($content, $terms);
$mergedRefs = $this->sortAndMergeMatchPositions($matchRefs);
$formatted = $this->formatTextUsingMatchPositions($mergedRefs, $content, $targetLength);
$entity->setAttribute($attributeName, new HtmlString($formatted));
}
$tags = $entity->relationLoaded('tags') ? $entity->tags->all() : [];
$this->highlightTagsContainingTerms($tags, $terms);
}
/**
* Highlight tags which match the given terms.
*
* @param Tag[] $tags
* @param string[] $terms
*/
protected function highlightTagsContainingTerms(array $tags, array $terms): void
{
foreach ($tags as $tag) {
$tagName = mb_strtolower($tag->name);
$tagValue = mb_strtolower($tag->value);
foreach ($terms as $term) {
$termLower = mb_strtolower($term);
if (mb_strpos($tagName, $termLower) !== false) {
$tag->setAttribute('highlight_name', true);
}
if (mb_strpos($tagValue, $termLower) !== false) {
$tag->setAttribute('highlight_value', true);
}
}
}
}
/**
* Get positions of the given terms within the given text.
* Is in the array format of [int $startIndex => int $endIndex] where the indexes
* are positions within the provided text.
*
* @return array<int, int>
*/
protected function getMatchPositions(string $text, array $terms): array
{
$matchRefs = [];
$text = mb_strtolower($text);
foreach ($terms as $term) {
$offset = 0;
$term = mb_strtolower($term);
$pos = mb_strpos($text, $term, $offset);
while ($pos !== false) {
$end = $pos + mb_strlen($term);
$matchRefs[$pos] = $end;
$offset = $end;
$pos = mb_strpos($text, $term, $offset);
}
}
return $matchRefs;
}
/**
* Sort the given match positions before merging them where they're
* adjacent or where they overlap.
*
* @param array<int, int> $matchPositions
*
* @return array<int, int>
*/
protected function sortAndMergeMatchPositions(array $matchPositions): array
{
ksort($matchPositions);
$mergedRefs = [];
$lastStart = 0;
$lastEnd = 0;
foreach ($matchPositions as $start => $end) {
if ($start > $lastEnd) {
$mergedRefs[$start] = $end;
$lastStart = $start;
$lastEnd = $end;
} elseif ($end > $lastEnd) {
$mergedRefs[$lastStart] = $end;
$lastEnd = $end;
}
}
return $mergedRefs;
}
/**
* Format the given original text, returning a version where terms are highlighted within.
* Returned content is in HTML text format.
* A given $targetLength of 0 asserts no target length limit.
*
* This is a complex function but written to be relatively efficient, going through the term matches in order
* so that we're only doing a one-time loop through of the matches. There is no further searching
* done within here.
*/
protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText, int $targetLength): string
{
$maxEnd = mb_strlen($originalText);
$fetchAll = ($targetLength === 0);
$contextLength = ($fetchAll ? 0 : 32);
$firstStart = null;
$lastEnd = 0;
$content = '';
$contentTextLength = 0;
if ($fetchAll) {
$targetLength = $maxEnd * 2;
}
foreach ($matchPositions as $start => $end) {
// Get our outer text ranges for the added context we want to show upon the result.
$contextStart = max($start - $contextLength, 0, $lastEnd);
$contextEnd = min($end + $contextLength, $maxEnd);
// Adjust the start if we're going to be touching the previous match.
$startDiff = $start - $lastEnd;
if ($startDiff < 0) {
$contextStart = $start;
// Trims off '$startDiff' number of characters to bring it back to the start
// if this current match zone.
$content = mb_substr($content, 0, mb_strlen($content) + $startDiff);
$contentTextLength += $startDiff;
}
// Add ellipsis between results
if (!$fetchAll && $contextStart !== 0 && $contextStart !== $start) {
$content .= ' ...';
$contentTextLength += 4;
} elseif ($fetchAll) {
// Or fill in gap since the previous match
$fillLength = $contextStart - $lastEnd;
$content .= e(mb_substr($originalText, $lastEnd, $fillLength));
$contentTextLength += $fillLength;
}
// Add our content including the bolded matching text
$content .= e(mb_substr($originalText, $contextStart, $start - $contextStart));
$contentTextLength += $start - $contextStart;
$content .= '<strong>' . e(mb_substr($originalText, $start, $end - $start)) . '</strong>';
$contentTextLength += $end - $start;
$content .= e(mb_substr($originalText, $end, $contextEnd - $end));
$contentTextLength += $contextEnd - $end;
// Update our last end position
$lastEnd = $contextEnd;
// Update the first start position if it's not already been set
if (is_null($firstStart)) {
$firstStart = $contextStart;
}
// Stop if we're near our target
if ($contentTextLength >= $targetLength - 10) {
break;
}
}
// Just copy out the content if we haven't moved along anywhere.
if ($lastEnd === 0) {
$content = e(mb_substr($originalText, 0, $targetLength));
$contentTextLength = $targetLength;
$lastEnd = $targetLength;
}
// Pad out the end if we're low
$remainder = $targetLength - $contentTextLength;
if ($remainder > 10) {
$padEndLength = min($maxEnd - $lastEnd, $remainder);
$content .= e(mb_substr($originalText, $lastEnd, $padEndLength));
$lastEnd += $padEndLength;
$contentTextLength += $padEndLength;
}
// Pad out the start if we're still low
$remainder = $targetLength - $contentTextLength;
$firstStart = $firstStart ?: 0;
if (!$fetchAll && $remainder > 10 && $firstStart !== 0) {
$padStart = max(0, $firstStart - $remainder);
$content = ($padStart === 0 ? '' : '...') . e(mb_substr($originalText, $padStart, $firstStart - $padStart)) . mb_substr($content, 4);
}
// Add ellipsis if we're not at the end
if ($lastEnd < $maxEnd) {
$content .= '...';
}
return $content;
}
}

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