Compare commits

...

110 Commits

Author SHA1 Message Date
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
362 changed files with 7378 additions and 2496 deletions

View File

@@ -100,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
@@ -134,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
@@ -242,6 +241,7 @@ 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

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
@@ -199,3 +199,14 @@ 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

View File

@@ -36,7 +36,7 @@ jobs:
- name: Start Database
run: |
sudo /etc/init.d/mysql start
sudo systemctl start mysql
- name: Setup Database
run: |

View File

@@ -36,7 +36,7 @@ jobs:
- name: Start MySQL
run: |
sudo /etc/init.d/mysql start
sudo systemctl start mysql
- name: Create database & user
run: |

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,200 +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
{
$ip = request()->ip() ?? '';
return $this->activity->newInstance()->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,
]);
}
/**
* 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

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

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;
}
}

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

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

@@ -165,7 +165,7 @@ class LdapService
* Bind the system user to the LDAP connection using the given credentials
* otherwise anonymous access is attempted.
*
* @param $connection
* @param resource $connection
*
* @throws LdapException
*/

View File

@@ -41,16 +41,18 @@ class OidcJwtSigningKey
protected function loadFromPath(string $path)
{
try {
$this->key = PublicKeyLoader::load(
$key = PublicKeyLoader::load(
file_get_contents($path)
)->withPadding(RSA::SIGNATURE_PKCS1);
);
} catch (\Exception $exception) {
throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}");
}
if (!($this->key instanceof RSA)) {
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);
}
/**
@@ -81,14 +83,19 @@ class OidcJwtSigningKey
$n = strtr($jwk['n'] ?? '', '-_', '+/');
try {
/** @var RSA $key */
$this->key = PublicKeyLoader::load([
$key = PublicKeyLoader::load([
'e' => new BigInteger(base64_decode($jwk['e']), 256),
'n' => new BigInteger(base64_decode($n), 256),
])->withPadding(RSA::SIGNATURE_PKCS1);
]);
} 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);
}
/**

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;
@@ -278,7 +279,7 @@ 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']);
}

View File

@@ -602,25 +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|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) {
/** @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('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();
@@ -634,25 +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) {
/** @var Builder $permissionQuery */
$permissionQuery->select('id')->from('joint_permissions')
->whereColumn('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;
@@ -666,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

@@ -2,7 +2,6 @@
namespace BookStack\Auth;
use Activity;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
@@ -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.
*/

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

View File

@@ -4,6 +4,9 @@ namespace BookStack\Console\Commands;
use BookStack\Auth\UserRepo;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Rules\Unique;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
class CreateAdmin extends Command
@@ -45,43 +48,33 @@ class CreateAdmin extends Command
*/
public function handle()
{
$email = trim($this->option('email'));
if (empty($email)) {
$email = $this->ask('Please specify an email address for the new admin user');
$details = $this->options();
if (empty($details['email'])) {
$details['email'] = $this->ask('Please specify an email address for the new admin user');
}
if (mb_strlen($email) < 5 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error('Invalid email address provided');
if (empty($details['name'])) {
$details['name'] = $this->ask('Please specify a name for the new admin user');
}
if (empty($details['password'])) {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
}
$validator = Validator::make($details, [
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
'name' => ['required', 'min:2'],
'password' => ['required', Password::default()],
]);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return SymfonyCommand::FAILURE;
}
if ($this->userRepo->getByEmail($email) !== null) {
$this->error('A user with the provided email already exists!');
return SymfonyCommand::FAILURE;
}
$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) {
$this->error('Invalid name provided');
return SymfonyCommand::FAILURE;
}
$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) {
$this->error('Invalid password provided, Must be at least 5 characters');
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;

View File

@@ -49,7 +49,7 @@ class RegenerateSearch extends Command
DB::setDefaultConnection($this->option('database'));
}
$this->searchIndex->indexAllEntities(function (Entity $model, int $processed, int $total) {
$this->searchIndex->indexAllEntities(function (Entity $model, int $processed, int $total): void {
$this->info('Indexed ' . class_basename($model) . ' entries (' . $processed . '/' . $total . ')');
});

View File

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

@@ -37,7 +37,7 @@ class Bookshelf extends Entity implements HasCoverImage
*/
public function visibleBooks(): BelongsToMany
{
return $this->books()->visible();
return $this->books()->scopes('visible');
}
/**

View File

@@ -18,11 +18,13 @@ class Chapter extends BookChild
public $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'priority', 'book_id'];
protected $fillable = ['name', 'description', 'priority'];
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
/**
* Get the pages that this chapter contains.
*
* @return HasMany<Page>
*/
public function pages(string $dir = 'ASC'): HasMany
{
@@ -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
{

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;
@@ -44,7 +46,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;
@@ -120,11 +122,11 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
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 +212,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
{
@@ -318,4 +322,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

@@ -63,10 +63,8 @@ class PageRevision extends Model
/**
* 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 +82,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;
}
@@ -177,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;
}
/**
@@ -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;
}
@@ -280,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();
}
@@ -290,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());
@@ -309,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;
}
@@ -325,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');
}
@@ -334,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];
@@ -406,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();
@@ -467,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 static();
$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;
}
/**
@@ -139,16 +142,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

@@ -12,6 +12,8 @@ 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;
@@ -156,7 +158,7 @@ class PageContent
/**
* Parse a base64 image URI into the data and extension.
*
* @return array{extension: array, data: string}
* @return array{extension: string, data: string}
*/
protected function parseBase64ImageUri(string $uri): array
{
@@ -193,6 +195,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) {
@@ -228,9 +239,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 ['', ''];
}
@@ -242,7 +253,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);
@@ -312,7 +323,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);

View File

@@ -0,0 +1,26 @@
<?php
namespace BookStack\Entities\Tools;
use Barryvdh\DomPDF\Facade as DomPDF;
use Barryvdh\Snappy\Facades\SnappyPdf;
class PdfGenerator
{
/**
* Generate PDF content from the given HTML content.
*/
public function fromHtml(string $html): string
{
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
if ($useWKHTML) {
$pdf = SnappyPDF::loadHTML($html);
$pdf->setOption('print-media-type', true);
} else {
$pdf = DomPDF::loadHTML($html);
}
return $pdf->output();
}
}

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

@@ -9,6 +9,7 @@ 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
@@ -67,7 +68,7 @@ class SearchIndex
* - The number that have been processed so far.
* - The total number of that model to be processed.
*
* @param callable(Entity, int, int)|null $progressCallback
* @param callable(Entity, int, int):void|null $progressCallback
*/
public function indexAllEntities(?callable $progressCallback = null)
{
@@ -76,7 +77,9 @@ class SearchIndex
foreach ($this->entityProvider->all() as $entityModel) {
$indexContentField = $entityModel instanceof Page ? 'html' : 'description';
$selectFields = ['id', 'name', $indexContentField];
$total = $entityModel->newQuery()->withTrashed()->count();
/** @var Builder<Entity> $query */
$query = $entityModel->newQuery();
$total = $query->withTrashed()->count();
$chunkSize = 250;
$processed = 0;
@@ -223,7 +226,7 @@ class SearchIndex
if ($entity instanceof Page) {
$bodyTermsMap = $this->generateTermScoreMapFromHtml($entity->html);
} else {
$bodyTermsMap = $this->generateTermScoreMapFromText($entity->description ?? '', $entity->searchFactor);
$bodyTermsMap = $this->generateTermScoreMapFromText($entity->getAttribute('description') ?? '', $entity->searchFactor);
}
$mergedScoreMap = $this->mergeTermScoreMaps($nameTermsMap, $bodyTermsMap, $tagTermsMap);

View File

@@ -57,17 +57,17 @@ class SearchResultsFormatter
protected function highlightTagsContainingTerms(array $tags, array $terms): void
{
foreach ($tags as $tag) {
$tagName = strtolower($tag->name);
$tagValue = strtolower($tag->value);
$tagName = mb_strtolower($tag->name);
$tagValue = mb_strtolower($tag->value);
foreach ($terms as $term) {
$termLower = strtolower($term);
$termLower = mb_strtolower($term);
if (strpos($tagName, $termLower) !== false) {
if (mb_strpos($tagName, $termLower) !== false) {
$tag->setAttribute('highlight_name', true);
}
if (strpos($tagValue, $termLower) !== false) {
if (mb_strpos($tagValue, $termLower) !== false) {
$tag->setAttribute('highlight_value', true);
}
}
@@ -84,17 +84,17 @@ class SearchResultsFormatter
protected function getMatchPositions(string $text, array $terms): array
{
$matchRefs = [];
$text = strtolower($text);
$text = mb_strtolower($text);
foreach ($terms as $term) {
$offset = 0;
$term = strtolower($term);
$pos = strpos($text, $term, $offset);
$term = mb_strtolower($term);
$pos = mb_strpos($text, $term, $offset);
while ($pos !== false) {
$end = $pos + strlen($term);
$end = $pos + mb_strlen($term);
$matchRefs[$pos] = $end;
$offset = $end;
$pos = strpos($text, $term, $offset);
$pos = mb_strpos($text, $term, $offset);
}
}
@@ -141,7 +141,7 @@ class SearchResultsFormatter
*/
protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText, int $targetLength): string
{
$maxEnd = strlen($originalText);
$maxEnd = mb_strlen($originalText);
$fetchAll = ($targetLength === 0);
$contextLength = ($fetchAll ? 0 : 32);
@@ -165,7 +165,7 @@ class SearchResultsFormatter
$contextStart = $start;
// Trims off '$startDiff' number of characters to bring it back to the start
// if this current match zone.
$content = substr($content, 0, strlen($content) + $startDiff);
$content = mb_substr($content, 0, mb_strlen($content) + $startDiff);
$contentTextLength += $startDiff;
}
@@ -176,16 +176,16 @@ class SearchResultsFormatter
} elseif ($fetchAll) {
// Or fill in gap since the previous match
$fillLength = $contextStart - $lastEnd;
$content .= e(substr($originalText, $lastEnd, $fillLength));
$content .= e(mb_substr($originalText, $lastEnd, $fillLength));
$contentTextLength += $fillLength;
}
// Add our content including the bolded matching text
$content .= e(substr($originalText, $contextStart, $start - $contextStart));
$content .= e(mb_substr($originalText, $contextStart, $start - $contextStart));
$contentTextLength += $start - $contextStart;
$content .= '<strong>' . e(substr($originalText, $start, $end - $start)) . '</strong>';
$content .= '<strong>' . e(mb_substr($originalText, $start, $end - $start)) . '</strong>';
$contentTextLength += $end - $start;
$content .= e(substr($originalText, $end, $contextEnd - $end));
$content .= e(mb_substr($originalText, $end, $contextEnd - $end));
$contentTextLength += $contextEnd - $end;
// Update our last end position
@@ -204,7 +204,7 @@ class SearchResultsFormatter
// Just copy out the content if we haven't moved along anywhere.
if ($lastEnd === 0) {
$content = e(substr($originalText, 0, $targetLength));
$content = e(mb_substr($originalText, 0, $targetLength));
$contentTextLength = $targetLength;
$lastEnd = $targetLength;
}
@@ -213,7 +213,7 @@ class SearchResultsFormatter
$remainder = $targetLength - $contentTextLength;
if ($remainder > 10) {
$padEndLength = min($maxEnd - $lastEnd, $remainder);
$content .= e(substr($originalText, $lastEnd, $padEndLength));
$content .= e(mb_substr($originalText, $lastEnd, $padEndLength));
$lastEnd += $padEndLength;
$contentTextLength += $padEndLength;
}
@@ -223,7 +223,7 @@ class SearchResultsFormatter
$firstStart = $firstStart ?: 0;
if (!$fetchAll && $remainder > 10 && $firstStart !== 0) {
$padStart = max(0, $firstStart - $remainder);
$content = ($padStart === 0 ? '' : '...') . e(substr($originalText, $padStart, $firstStart - $padStart)) . substr($content, 4);
$content = ($padStart === 0 ? '' : '...') . e(mb_substr($originalText, $padStart, $firstStart - $padStart)) . mb_substr($content, 4);
}
// Add ellipsis if we're not at the end

View File

@@ -9,6 +9,7 @@ use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\SearchTerm;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -144,13 +145,13 @@ class SearchRunner
if ($entityModelInstance instanceof BookChild) {
$relations['book'] = function (BelongsTo $query) {
$query->visible();
$query->scopes('visible');
};
}
if ($entityModelInstance instanceof Page) {
$relations['chapter'] = function (BelongsTo $query) {
$query->visible();
$query->scopes('visible');
};
}
@@ -356,7 +357,9 @@ class SearchRunner
// We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
// search the value as a string which prevents being able to do number-based operations
// on the tag values. We ensure it has a numeric value and then cast it just to be sure.
$tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'");
/** @var Connection $connection */
$connection = $query->getConnection();
$tagValue = (float) trim($connection->getPdo()->quote($tagValue), "'");
$query->whereRaw("value ${tagOperator} ${tagValue}");
} else {
$query->where('value', $tagOperator, $tagValue);

View File

@@ -5,6 +5,7 @@ namespace BookStack\Entities\Tools;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use Illuminate\Support\Collection;
@@ -24,7 +25,7 @@ class SiblingFetcher
}
// Page in book or chapter
if (($entity instanceof Page && !$entity->chapter) || $entity->isA('chapter')) {
if (($entity instanceof Page && !$entity->chapter) || $entity instanceof Chapter) {
$entities = $entity->book->getDirectChildren();
}

View File

@@ -15,6 +15,7 @@ use BookStack\Facades\Activity;
use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\ImageService;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class TrashCan
@@ -141,11 +142,9 @@ class TrashCan
{
$count = 0;
$pages = $chapter->pages()->withTrashed()->get();
if (count($pages)) {
foreach ($pages as $page) {
$this->destroyPage($page);
$count++;
}
foreach ($pages as $page) {
$this->destroyPage($page);
$count++;
}
$this->destroyCommonRelations($chapter);
@@ -183,9 +182,10 @@ class TrashCan
{
$counts = [];
/** @var Entity $instance */
foreach ((new EntityProvider())->all() as $key => $instance) {
$counts[$key] = $instance->newQuery()->onlyTrashed()->count();
/** @var Builder<Entity> $query */
$query = $instance->newQuery();
$counts[$key] = $query->onlyTrashed()->count();
}
return $counts;
@@ -235,13 +235,15 @@ class TrashCan
{
$shouldRestore = true;
$restoreCount = 0;
$parent = $deletion->deletable->getParent();
if ($parent && $parent->trashed()) {
$shouldRestore = false;
if ($deletion->deletable instanceof Entity) {
$parent = $deletion->deletable->getParent();
if ($parent && $parent->trashed()) {
$shouldRestore = false;
}
}
if ($shouldRestore) {
if ($deletion->deletable instanceof Entity && $shouldRestore) {
$restoreCount = $this->restoreEntity($deletion->deletable);
}
@@ -342,9 +344,9 @@ class TrashCan
$entity->deletions()->delete();
$entity->favourites()->delete();
if ($entity instanceof HasCoverImage && $entity->cover) {
if ($entity instanceof HasCoverImage && $entity->cover()->exists()) {
$imageService = app()->make(ImageService::class);
$imageService->destroy($entity->cover);
$imageService->destroy($entity->cover()->first());
}
}
}

View File

@@ -4,6 +4,7 @@ namespace BookStack\Exceptions;
use Exception;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -75,15 +76,20 @@ class Handler extends ExceptionHandler
/**
* Render an exception when the API is in use.
*/
protected function renderApiException(Exception $e): JsonResponse
protected function renderApiException(Throwable $e): JsonResponse
{
$code = $e->getCode() === 0 ? 500 : $e->getCode();
$code = 500;
$headers = [];
if ($e instanceof HttpException) {
$code = $e->getStatusCode();
$headers = $e->getHeaders();
}
if ($e instanceof ModelNotFoundException) {
$code = 404;
}
$responseData = [
'error' => [
'message' => $e->getMessage(),

View File

@@ -1,9 +0,0 @@
<?php
namespace BookStack\Exceptions;
use Exception;
class SortOperationException extends Exception
{
}

View File

@@ -4,6 +4,9 @@ namespace BookStack\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @see \BookStack\Actions\ActivityLogger
*/
class Activity extends Facade
{
/**

View File

@@ -75,7 +75,7 @@ class BookshelfApiController extends ApiController
$shelf = Bookshelf::visible()->with([
'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
'books' => function (BelongsToMany $query) {
$query->visible()->get(['id', 'name', 'slug']);
$query->scopes('visible')->get(['id', 'name', 'slug']);
},
])->findOrFail($id);

View File

@@ -70,7 +70,7 @@ class ChapterApiController extends ApiController
public function read(string $id)
{
$chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'ownedBy', 'pages' => function (HasMany $query) {
$query->visible()->get(['id', 'name', 'slug']);
$query->scopes('visible')->get(['id', 'name', 'slug']);
}])->findOrFail($id);
return response()->json($chapter);

View File

@@ -4,12 +4,14 @@ namespace BookStack\Http\Controllers\Api;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\SearchOptions;
use BookStack\Entities\Tools\SearchResultsFormatter;
use BookStack\Entities\Tools\SearchRunner;
use Illuminate\Http\Request;
class SearchApiController extends ApiController
{
protected $searchRunner;
protected $resultsFormatter;
protected $rules = [
'all' => [
@@ -19,9 +21,10 @@ class SearchApiController extends ApiController
],
];
public function __construct(SearchRunner $searchRunner)
public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter)
{
$this->searchRunner = $searchRunner;
$this->resultsFormatter = $resultsFormatter;
}
/**
@@ -45,6 +48,7 @@ class SearchApiController extends ApiController
$count = min(intval($request->get('count', '0')) ?: 20, 100);
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
$this->resultsFormatter->format($results['results']->all(), $options);
/** @var Entity $result */
foreach ($results['results'] as $result) {
@@ -52,9 +56,14 @@ class SearchApiController extends ApiController
'id', 'name', 'slug', 'book_id',
'chapter_id', 'draft', 'template',
'created_at', 'updated_at',
'tags', 'type',
'tags', 'type', 'preview_html', 'url',
]);
$result->setAttribute('type', $result->getType());
$result->setAttribute('url', $result->getUrl());
$result->setAttribute('preview_html', [
'name' => (string) $result->getAttribute('preview_name'),
'content' => (string) $result->getAttribute('preview_content'),
]);
}
return response()->json([

View File

@@ -20,6 +20,7 @@ class AuditLogController extends Controller
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'user' => $request->get('user', ''),
'ip' => $request->get('ip', ''),
];
$query = Activity::query()
@@ -44,6 +45,9 @@ class AuditLogController extends Controller
if ($listDetails['date_to']) {
$query->where('created_at', '<=', $listDetails['date_to']);
}
if ($listDetails['ip']) {
$query->where('ip', 'like', $listDetails['ip'] . '%');
}
$activities = $query->paginate(100);
$activities->appends($listDetails);

View File

@@ -2,11 +2,11 @@
namespace BookStack\Http\Controllers\Auth;
use Activity;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Facades\Activity;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;

View File

@@ -29,6 +29,8 @@ class MfaBackupCodesController extends Controller
$downloadUrl = 'data:application/octet-stream;base64,' . base64_encode(implode("\n\n", $codes));
$this->setPageTitle(trans('auth.mfa_gen_backup_codes_title'));
return view('mfa.backup-codes-generate', [
'codes' => $codes,
'downloadUrl' => $downloadUrl,

View File

@@ -21,6 +21,8 @@ class MfaController extends Controller
->get(['id', 'method'])
->groupBy('method');
$this->setPageTitle(trans('auth.mfa_setup'));
return view('mfa.setup', [
'userMethods' => $userMethods,
]);

View File

@@ -34,6 +34,8 @@ class MfaTotpController extends Controller
$qrCodeUrl = $totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
$svg = $totp->generateQrCodeSvg($qrCodeUrl);
$this->setPageTitle(trans('auth.mfa_gen_totp_title'));
return view('mfa.totp-generate', [
'url' => $qrCodeUrl,
'svg' => $svg,

View File

@@ -13,6 +13,7 @@ use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
@@ -70,7 +71,7 @@ class RegisterController extends Controller
return Validator::make($data, [
'name' => ['required', 'min:2', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'min:8'],
'password' => ['required', Password::default()],
]);
}

View File

@@ -11,6 +11,7 @@ use Exception;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Validation\Rules\Password;
class UserInviteController extends Controller
{
@@ -55,7 +56,7 @@ class UserInviteController extends Controller
public function setPassword(Request $request, string $token)
{
$this->validate($request, [
'password' => ['required', 'min:8'],
'password' => ['required', Password::default()],
]);
try {

View File

@@ -2,15 +2,18 @@
namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Actions\ActivityQueries;
use BookStack\Actions\ActivityType;
use BookStack\Actions\View;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Throwable;
@@ -101,7 +104,7 @@ class BookController extends Controller
if ($bookshelf) {
$bookshelf->appendBook($book);
Activity::addForEntity($bookshelf, ActivityType::BOOKSHELF_UPDATE);
Activity::add(ActivityType::BOOKSHELF_UPDATE, $bookshelf);
}
return redirect($book->getUrl());
@@ -110,11 +113,11 @@ class BookController extends Controller
/**
* Display the specified book.
*/
public function show(Request $request, string $slug)
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$book = $this->bookRepo->getBySlug($slug);
$bookChildren = (new BookContents($book))->getTree(true);
$bookParentShelves = $book->shelves()->visible()->get();
$bookParentShelves = $book->shelves()->scopes('visible')->get();
View::incrementFor($book);
if ($request->has('shelf')) {
@@ -128,7 +131,7 @@ class BookController extends Controller
'current' => $book,
'bookChildren' => $bookChildren,
'bookParentShelves' => $bookParentShelves,
'activity' => Activity::entityActivity($book, 20, 1),
'activity' => $activities->entityActivity($book, 20, 1),
]);
}
@@ -224,4 +227,39 @@ class BookController extends Controller
return redirect($book->getUrl());
}
/**
* Show the view to copy a book.
*
* @throws NotFoundException
*/
public function showCopy(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-view', $book);
session()->flashInput(['name' => $book->name]);
return view('books.copy', [
'book' => $book,
]);
}
/**
* Create a copy of a book within the requested target destination.
*
* @throws NotFoundException
*/
public function copy(Request $request, Cloner $cloner, string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-view', $book);
$this->checkPermission('book-create-all');
$newName = $request->get('name') ?: $book->name;
$bookCopy = $cloner->cloneBook($book, $newName);
$this->showSuccessNotification(trans('entities.books_copy_success'));
return redirect($bookCopy->getUrl());
}
}

View File

@@ -3,10 +3,9 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Exceptions\SortOperationException;
use BookStack\Entities\Tools\BookSortMap;
use BookStack\Facades\Activity;
use Illuminate\Http\Request;
@@ -59,20 +58,14 @@ class BookSortController extends Controller
return redirect($book->getUrl());
}
$sortMap = collect(json_decode($request->get('sort-tree')));
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
$bookContents = new BookContents($book);
$booksInvolved = collect();
try {
$booksInvolved = $bookContents->sortUsingMap($sortMap);
} catch (SortOperationException $exception) {
$this->showPermissionError();
}
$booksInvolved = $bookContents->sortUsingMap($sortMap);
// Rebuild permissions and add activity for involved books.
$booksInvolved->each(function (Book $book) {
Activity::addForEntity($book, ActivityType::BOOK_SORT);
});
foreach ($booksInvolved as $bookInvolved) {
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
}
return redirect($book->getUrl());
}

View File

@@ -2,7 +2,7 @@
namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Actions\ActivityQueries;
use BookStack\Actions\View;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\BookshelfRepo;
@@ -101,7 +101,7 @@ class BookshelfController extends Controller
*
* @throws NotFoundException
*/
public function show(string $slug)
public function show(ActivityQueries $activities, string $slug)
{
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('book-view', $shelf);
@@ -124,7 +124,7 @@ class BookshelfController extends Controller
'shelf' => $shelf,
'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
'view' => $view,
'activity' => Activity::entityActivity($shelf, 20, 1),
'activity' => $activities->entityActivity($shelf, 20, 1),
'order' => $order,
'sort' => $sort,
]);

View File

@@ -6,10 +6,12 @@ use BookStack\Actions\View;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
use BookStack\Entities\Tools\NextPreviousContentLocator;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Throwable;
@@ -179,6 +181,8 @@ class ChapterController extends Controller
try {
$newBook = $this->chapterRepo->move($chapter, $entitySelection);
} catch (PermissionsException $exception) {
$this->showPermissionError();
} catch (MoveOperationException $exception) {
$this->showErrorNotification(trans('errors.selected_book_not_found'));
@@ -190,6 +194,53 @@ class ChapterController extends Controller
return redirect($chapter->getUrl());
}
/**
* Show the view to copy a chapter.
*
* @throws NotFoundException
*/
public function showCopy(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
session()->flashInput(['name' => $chapter->name]);
return view('chapters.copy', [
'book' => $chapter->book,
'chapter' => $chapter,
]);
}
/**
* Create a copy of a chapter within the requested target destination.
*
* @throws NotFoundException
* @throws Throwable
*/
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
$entitySelection = $request->get('entity_selection') ?: null;
$newParentBook = $entitySelection ? $this->chapterRepo->findParentByIdentifier($entitySelection) : $chapter->getParent();
if (is_null($newParentBook)) {
$this->showErrorNotification(trans('errors.selected_book_not_found'));
return redirect()->back();
}
$this->checkOwnablePermission('chapter-create', $newParentBook);
$newName = $request->get('name') ?: $chapter->name;
$chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
$this->showSuccessNotification(trans('entities.chapters_copy_success'));
return redirect($chapterCopy->getUrl());
}
/**
* Show the Restrictions view.
*

View File

@@ -21,6 +21,8 @@ class FavouriteController extends Controller
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
$this->setPageTitle(trans('entities.my_favourites'));
return view('common.detailed-listing-with-more', [
'title' => trans('entities.my_favourites'),
'entities' => $favourites->slice(0, $viewCount),

View File

@@ -2,7 +2,7 @@
namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Actions\ActivityQueries;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\RecentlyViewed;
@@ -16,9 +16,9 @@ class HomeController extends Controller
/**
* Display the homepage.
*/
public function index()
public function index(ActivityQueries $activities)
{
$activity = Activity::latest(10);
$activity = $activities->latest(10);
$draftPages = [];
if ($this->isSignedIn()) {
@@ -39,7 +39,7 @@ class HomeController extends Controller
$recentlyUpdatedPages = Page::visible()->with('book')
->where('draft', false)
->orderBy('updated_at', 'desc')
->take($favourites->count() > 0 ? 6 : 12)
->take($favourites->count() > 0 ? 5 : 10)
->select(Page::$listAttributes)
->get();

View File

@@ -67,7 +67,7 @@ class MaintenanceController extends Controller
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
try {
user()->notify(new TestEmail());
user()->notifyNow(new TestEmail());
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
} catch (\Exception $exception) {
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();

View File

@@ -6,6 +6,7 @@ use BookStack\Actions\View;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
use BookStack\Entities\Tools\NextPreviousContentLocator;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditActivity;
@@ -367,6 +368,8 @@ class PageController extends Controller
->paginate(20)
->setPath(url('/pages/recently-updated'));
$this->setPageTitle(trans('entities.recently_updated_pages'));
return view('common.detailed-listing-paginated', [
'title' => trans('entities.recently_updated_pages'),
'entities' => $pages,
@@ -409,11 +412,9 @@ class PageController extends Controller
try {
$parent = $this->pageRepo->move($page, $entitySelection);
} catch (PermissionsException $exception) {
$this->showPermissionError();
} catch (Exception $exception) {
if ($exception instanceof PermissionsException) {
$this->showPermissionError();
}
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect()->back();
@@ -447,26 +448,24 @@ class PageController extends Controller
* @throws NotFoundException
* @throws Throwable
*/
public function copy(Request $request, string $bookSlug, string $pageSlug)
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-view', $page);
$entitySelection = $request->get('entity_selection', null) ?? null;
$newName = $request->get('name', null);
try {
$pageCopy = $this->pageRepo->copy($page, $entitySelection, $newName);
} catch (Exception $exception) {
if ($exception instanceof PermissionsException) {
$this->showPermissionError();
}
$entitySelection = $request->get('entity_selection') ?: null;
$newParent = $entitySelection ? $this->pageRepo->findParentByIdentifier($entitySelection) : $page->getParent();
if (is_null($newParent)) {
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect()->back();
}
$this->checkOwnablePermission('page-create', $newParent);
$newName = $request->get('name') ?: $page->name;
$pageCopy = $cloner->clonePage($page, $newParent, $newName);
$this->showSuccessNotification(trans('entities.pages_copy_success'));
return redirect($pageCopy->getUrl());

View File

@@ -58,6 +58,7 @@ class RecycleBinController extends Controller
$searching = false;
}
}
/** @var ?Deletion $parentDeletion */
$parentDeletion = ($currentDeletable === $deletion->deletable) ? null : $currentDeletable->deletions()->first();

View File

@@ -3,6 +3,7 @@
namespace BookStack\Http\Controllers;
use BookStack\Auth\Permissions\PermissionsRepo;
use BookStack\Auth\Role;
use BookStack\Exceptions\PermissionsException;
use Exception;
use Illuminate\Http\Request;
@@ -23,22 +24,36 @@ class RoleController extends Controller
/**
* Show a listing of the roles in the system.
*/
public function list()
public function index()
{
$this->checkPermission('user-roles-manage');
$roles = $this->permissionsRepo->getAllRoles();
$this->setPageTitle(trans('settings.roles'));
return view('settings.roles.index', ['roles' => $roles]);
}
/**
* Show the form to create a new role.
*/
public function create()
public function create(Request $request)
{
$this->checkPermission('user-roles-manage');
return view('settings.roles.create');
/** @var ?Role $role */
$role = null;
if ($request->has('copy_from')) {
$role = Role::query()->find($request->get('copy_from'));
}
if ($role) {
$role->display_name .= ' (' . trans('common.copy') . ')';
}
$this->setPageTitle(trans('settings.role_create'));
return view('settings.roles.create', ['role' => $role]);
}
/**
@@ -49,7 +64,7 @@ class RoleController extends Controller
$this->checkPermission('user-roles-manage');
$this->validate($request, [
'display_name' => ['required', 'min:3', 'max:180'],
'description' => 'max:180',
'description' => ['max:180'],
]);
$this->permissionsRepo->saveNewRole($request->all());
@@ -71,6 +86,8 @@ class RoleController extends Controller
throw new PermissionsException(trans('errors.role_cannot_be_edited'));
}
$this->setPageTitle(trans('settings.role_edit'));
return view('settings.roles.edit', ['role' => $role]);
}
@@ -84,7 +101,7 @@ class RoleController extends Controller
$this->checkPermission('user-roles-manage');
$this->validate($request, [
'display_name' => ['required', 'min:3', 'max:180'],
'description' => 'max:180',
'description' => ['max:180'],
]);
$this->permissionsRepo->updateRole($id, $request->all());
@@ -105,6 +122,8 @@ class RoleController extends Controller
$blankRole = $role->newInstance(['display_name' => trans('settings.role_delete_no_migration')]);
$roles->prepend($blankRole);
$this->setPageTitle(trans('settings.role_delete'));
return view('settings.roles.delete', ['role' => $role, 'roles' => $roles]);
}

View File

@@ -32,6 +32,8 @@ class TagController extends Controller
'name' => $nameFilter,
]));
$this->setPageTitle(trans('entities.tags'));
return view('tags.index', [
'tags' => $tags,
'search' => $search,

View File

@@ -13,6 +13,7 @@ use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
class UserController extends Controller
@@ -82,7 +83,7 @@ class UserController extends Controller
$sendInvite = ($request->get('send_invite', 'false') === 'true');
if ($authMethod === 'standard' && !$sendInvite) {
$validationRules['password'] = ['required', 'min:6'];
$validationRules['password'] = ['required', Password::default()];
$validationRules['password-confirm'] = ['required', 'same:password'];
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
$validationRules['external_auth_id'] = ['required'];
@@ -155,11 +156,11 @@ class UserController extends Controller
$this->checkPermissionOrCurrentUser('users-manage', $id);
$this->validate($request, [
'name' => 'min:2',
'name' => ['min:2'],
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
'password' => ['min:6', 'required_with:password_confirm'],
'password' => ['required_with:password_confirm', Password::default()],
'password-confirm' => ['same:password', 'required_with:password'],
'setting' => 'array',
'setting' => ['array'],
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
]);

View File

@@ -2,6 +2,7 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityQueries;
use BookStack\Auth\UserRepo;
class UserProfileController extends Controller
@@ -9,14 +10,16 @@ class UserProfileController extends Controller
/**
* Show the user profile page.
*/
public function show(UserRepo $repo, string $slug)
public function show(UserRepo $repo, ActivityQueries $activities, string $slug)
{
$user = $repo->getBySlug($slug);
$userActivity = $repo->getActivity($user);
$userActivity = $activities->userActivity($user);
$recentlyCreated = $repo->getRecentlyCreated($user, 5);
$assetCounts = $repo->getAssetCounts($user);
$this->setPageTitle($user->name);
return view('users.profile', [
'user' => $user,
'activity' => $userActivity,

View File

@@ -3,7 +3,6 @@
namespace BookStack\Http\Controllers;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class UserSearchController extends Controller
@@ -14,19 +13,27 @@ class UserSearchController extends Controller
*/
public function forSelect(Request $request)
{
$hasPermission = signedInUser() && (
userCan('users-manage')
|| userCan('restrictions-manage-own')
|| userCan('restrictions-manage-all')
);
if (!$hasPermission) {
$this->showPermissionError();
}
$search = $request->get('search', '');
$query = User::query()->orderBy('name', 'desc')
$query = User::query()
->orderBy('name', 'asc')
->take(20);
if (!empty($search)) {
$query->where(function (Builder $query) use ($search) {
$query->where('email', 'like', '%' . $search . '%')
->orWhere('name', 'like', '%' . $search . '%');
});
$query->where('name', 'like', '%' . $search . '%');
}
$users = $query->get();
return view('form.user-select-list', compact('users'));
return view('form.user-select-list', [
'users' => $query->get(),
]);
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Actions\Webhook;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function __construct()
{
$this->middleware([
'can:settings-manage',
]);
}
/**
* Show all webhooks configured in the system.
*/
public function index()
{
$webhooks = Webhook::query()
->orderBy('name', 'desc')
->with('trackedEvents')
->get();
$this->setPageTitle(trans('settings.webhooks'));
return view('settings.webhooks.index', ['webhooks' => $webhooks]);
}
/**
* Show the view for creating a new webhook in the system.
*/
public function create()
{
$this->setPageTitle(trans('settings.webhooks_create'));
return view('settings.webhooks.create');
}
/**
* Store a new webhook in the system.
*/
public function store(Request $request)
{
$validated = $this->validate($request, [
'name' => ['required', 'max:150'],
'endpoint' => ['required', 'url', 'max:500'],
'events' => ['required', 'array'],
'active' => ['required'],
'timeout' => ['required', 'integer', 'min:1', 'max:600'],
]);
$webhook = new Webhook($validated);
$webhook->active = $validated['active'] === 'true';
$webhook->save();
$webhook->updateTrackedEvents(array_values($validated['events']));
$this->logActivity(ActivityType::WEBHOOK_CREATE, $webhook);
return redirect('/settings/webhooks');
}
/**
* Show the view to edit an existing webhook.
*/
public function edit(string $id)
{
/** @var Webhook $webhook */
$webhook = Webhook::query()
->with('trackedEvents')
->findOrFail($id);
$this->setPageTitle(trans('settings.webhooks_edit'));
return view('settings.webhooks.edit', ['webhook' => $webhook]);
}
/**
* Update an existing webhook with the provided request data.
*/
public function update(Request $request, string $id)
{
$validated = $this->validate($request, [
'name' => ['required', 'max:150'],
'endpoint' => ['required', 'url', 'max:500'],
'events' => ['required', 'array'],
'active' => ['required'],
'timeout' => ['required', 'integer', 'min:1', 'max:600'],
]);
/** @var Webhook $webhook */
$webhook = Webhook::query()->findOrFail($id);
$webhook->active = $validated['active'] === 'true';
$webhook->fill($validated)->save();
$webhook->updateTrackedEvents($validated['events']);
$this->logActivity(ActivityType::WEBHOOK_UPDATE, $webhook);
return redirect('/settings/webhooks');
}
/**
* Show the view to delete a webhook.
*/
public function delete(string $id)
{
/** @var Webhook $webhook */
$webhook = Webhook::query()->findOrFail($id);
$this->setPageTitle(trans('settings.webhooks_delete'));
return view('settings.webhooks.delete', ['webhook' => $webhook]);
}
/**
* Destroy a webhook from the system.
*/
public function destroy(string $id)
{
/** @var Webhook $webhook */
$webhook = Webhook::query()->findOrFail($id);
$webhook->trackedEvents()->delete();
$webhook->delete();
$this->logActivity(ActivityType::WEBHOOK_DELETE, $webhook);
return redirect('/settings/webhooks');
}
}

View File

@@ -35,7 +35,7 @@ class ApiAuthenticate
// Return if the user is already found to be signed in via session-based auth.
// This is to make it easy to browser the API via browser after just logging into the system.
if (signedInUser() || session()->isStarted()) {
if (!user()->can('access-api')) {
if (!$this->sessionUserHasApiAccess()) {
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
}
@@ -49,6 +49,16 @@ class ApiAuthenticate
auth()->authenticate();
}
/**
* Check if the active session user has API access.
*/
protected function sessionUserHasApiAccess(): bool
{
$hasApiPermission = user()->can('access-api');
return $hasApiPermission && hasAppAccess();
}
/**
* Provide a standard API unauthorised response.
*/

View File

@@ -0,0 +1,14 @@
<?php
namespace BookStack\Interfaces;
use Illuminate\Database\Eloquent\Relations\MorphMany;
/**
* A model that can be deleted in a manner that deletions
* are tracked to be part of the recycle bin system.
*/
interface Deletable
{
public function deletions(): MorphMany;
}

View File

@@ -11,6 +11,7 @@ use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AuthServiceProvider extends ServiceProvider
{
@@ -21,6 +22,12 @@ class AuthServiceProvider extends ServiceProvider
*/
public function boot()
{
// Password Configuration
Password::defaults(function () {
return Password::min(8);
});
// Custom guards
Auth::extend('api-token', function ($app, $name, array $config) {
return new ApiTokenGuard($app['request'], $app->make(LoginService::class));
});

View File

@@ -2,7 +2,7 @@
namespace BookStack\Providers;
use BookStack\Actions\ActivityService;
use BookStack\Actions\ActivityLogger;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Theming\ThemeService;
use BookStack\Uploads\ImageService;
@@ -28,7 +28,7 @@ class CustomFacadeProvider extends ServiceProvider
public function register()
{
$this->app->singleton('activity', function () {
return $this->app->make(ActivityService::class);
return $this->app->make(ActivityLogger::class);
});
$this->app->singleton('images', function () {

View File

@@ -79,4 +79,20 @@ class ThemeEvents
* @returns \League\CommonMark\ConfigurableEnvironmentInterface|null
*/
const COMMONMARK_ENVIRONMENT_CONFIGURE = 'commonmark_environment_configure';
/**
* Webhook call before event.
* Runs before a webhook endpoint is called. Allows for customization
* of the data format & content within the webhook POST request.
* Provides the original event name as a string (see \BookStack\Actions\ActivityType)
* along with the webhook instance along with the event detail which may be a
* "Loggable" model type or a string.
* If the listener returns a non-null value, that will be used as the POST data instead
* of the system default.
*
* @param string $event
* @param \BookStack\Actions\Webhook $webhook
* @param string|\BookStack\Interfaces\Loggable $detail
*/
const WEBHOOK_CALL_BEFORE = 'webhook_call_before';
}

View File

@@ -3,6 +3,9 @@
namespace BookStack\Theming;
use BookStack\Auth\Access\SocialAuthService;
use Illuminate\Console\Application;
use Illuminate\Console\Application as Artisan;
use Symfony\Component\Console\Command\Command;
class ThemeService
{
@@ -43,6 +46,16 @@ class ThemeService
return null;
}
/**
* Register a new custom artisan command to be available.
*/
public function registerCommand(Command $command)
{
Artisan::starting(function (Application $application) use ($command) {
$application->addCommands([$command]);
});
}
/**
* Read any actions from the set theme path if the 'functions.php' file exists.
*/

View File

@@ -103,7 +103,10 @@ class ImageRepo
if ($filterType === 'page') {
$query->where('uploaded_to', '=', $contextPage->id);
} elseif ($filterType === 'book') {
$validPageIds = $contextPage->book->pages()->visible()->pluck('id')->toArray();
$validPageIds = $contextPage->book->pages()
->scopes('visible')
->pluck('id')
->toArray();
$query->whereIn('uploaded_to', $validPageIds);
}
};

View File

@@ -228,6 +228,21 @@ class ImageService
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
}
/**
* Check if the given image and image data is apng.
*/
protected function isApngData(Image $image, string &$imageData): bool
{
$isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
if (!$isPng) {
return false;
}
$initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
return strpos($initialHeader, 'acTL') !== false;
}
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
@@ -238,6 +253,7 @@ class ImageService
*/
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
{
// Do not resize GIF images where we're not cropping
if ($keepRatio && $this->isGif($image)) {
return $this->getPublicUrl($image->path);
}
@@ -246,19 +262,35 @@ class ImageService
$imagePath = $image->path;
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
return $this->getPublicUrl($thumbFilePath);
$thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
// Return path if in cache
$cachedThumbPath = $this->cache->get($thumbCacheKey);
if ($cachedThumbPath) {
return $this->getPublicUrl($cachedThumbPath);
}
// If thumbnail has already been generated, serve that and cache path
$storage = $this->getStorageDisk($image->type);
if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}
$thumbData = $this->resizeImage($storage->get($this->adjustPathForStorageDisk($imagePath, $image->type)), $width, $height, $keepRatio);
$imageData = $storage->get($this->adjustPathForStorageDisk($imagePath, $image->type));
// Do not resize apng images where we're not cropping
if ($keepRatio && $this->isApngData($image, $imageData)) {
$this->cache->put($thumbCacheKey, $image->path, 60 * 60 * 72);
return $this->getPublicUrl($image->path);
}
// If not in cache and thumbnail does not exist, generate thumb and cache path
$thumbData = $this->resizeImage($imageData, $width, $height, $keepRatio);
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
$this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}

View File

@@ -17,6 +17,7 @@ class WebSafeMimeSniffer
'application/json',
'application/octet-stream',
'application/pdf',
'image/apng',
'image/bmp',
'image/jpeg',
'image/png',

1083
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
<?php
namespace Database\Factories\Actions;
use BookStack\Actions\Webhook;
use Illuminate\Database\Eloquent\Factories\Factory;
class WebhookFactory extends Factory
{
protected $model = Webhook::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'name' => 'My webhook for ' . $this->faker->country(),
'endpoint' => $this->faker->url,
'active' => true,
'timeout' => 3,
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Database\Factories\Actions;
use BookStack\Actions\ActivityType;
use BookStack\Actions\Webhook;
use Illuminate\Database\Eloquent\Factories\Factory;
class WebhookTrackedEventFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'webhook_id' => Webhook::factory(),
'event' => ActivityType::all()[array_rand(ActivityType::all())],
];
}
}

View File

@@ -2,6 +2,7 @@
namespace Database\Factories\Auth;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
@@ -12,7 +13,7 @@ class UserFactory extends Factory
*
* @var string
*/
protected $model = \BookStack\Auth\User::class;
protected $model = User::class;
/**
* Define the model's default state.
@@ -26,7 +27,7 @@ class UserFactory extends Factory
return [
'name' => $name,
'email' => $this->faker->email,
'slug' => \Illuminate\Support\Str::slug($name . '-' . \Illuminate\Support\Str::random(5)),
'slug' => Str::slug($name . '-' . Str::random(5)),
'password' => Str::random(10),
'remember_token' => Str::random(10),
'email_confirmed' => 1,

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddIndexForUserIp extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('activities', function (Blueprint $table) {
$table->index('ip', 'activities_ip_index');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('activities', function (Blueprint $table) {
$table->dropIndex('activities_ip_index');
});
}
}

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateWebhooksTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('webhooks', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 150);
$table->boolean('active');
$table->string('endpoint', 500);
$table->timestamps();
$table->index('name');
$table->index('active');
});
Schema::create('webhook_tracked_events', function (Blueprint $table) {
$table->increments('id');
$table->integer('webhook_id');
$table->string('event', 50);
$table->timestamps();
$table->index('event');
$table->index('webhook_id');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('webhooks');
Schema::dropIfExists('webhook_tracked_events');
}
}

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateJobsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('jobs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('jobs');
}
}

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateFailedJobsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('failed_jobs');
}
}

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddWebhooksTimeoutErrorColumns extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('webhooks', function (Blueprint $table) {
$table->unsignedInteger('timeout')->default(3);
$table->text('last_error')->default('');
$table->timestamp('last_called_at')->nullable();
$table->timestamp('last_errored_at')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('webhooks', function (Blueprint $table) {
$table->dropColumn('timeout');
$table->dropColumn('last_error');
$table->dropColumn('last_called_at');
$table->dropColumn('last_errored_at');
});
}
}

View File

@@ -8,6 +8,11 @@
"created_at": "2021-11-14T15:57:35.000000Z",
"updated_at": "2021-11-14T15:57:35.000000Z",
"type": "chapter",
"url": "https://example.com/books/my-book/chapter/a-chapter-for-cats",
"preview_html": {
"name": "A chapter for <strong>cats</strong>",
"content": "...once a bunch of <strong>cats</strong> named tony...behaviour of <strong>cats</strong> is unsuitable"
},
"tags": []
},
{
@@ -21,6 +26,11 @@
"created_at": "2021-05-15T16:28:10.000000Z",
"updated_at": "2021-11-14T15:56:49.000000Z",
"type": "page",
"url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats",
"preview_html": {
"name": "The hows and whys of <strong>cats</strong>",
"content": "...people ask why <strong>cats</strong>? but there are...the reason that <strong>cats</strong> are fast are due to..."
},
"tags": [
{
"name": "Animal",
@@ -45,6 +55,11 @@
"created_at": "2020-11-29T21:55:07.000000Z",
"updated_at": "2021-11-14T16:02:39.000000Z",
"type": "page",
"url": "https://example.com/books/my-book/page/how-advanced-are-cats",
"preview_html": {
"name": "How advanced are <strong>cats</strong>?",
"content": "<strong>cats</strong> are some of the most advanced animals in the world."
},
"tags": []
}
],

View File

@@ -6,6 +6,8 @@ WARNING: This system is currently in alpha so may incur changes. Once we've gath
## Getting Started
*[Video Guide](https://www.youtube.com/watch?v=YVbpm_35crQ)*
This makes use of the theme system. Create a folder for your theme within your BookStack `themes` directory. As an example we'll use `my_theme`, so we'd create a `themes/my_theme` folder.
You'll need to tell BookStack to use your theme via the `APP_THEME` option in your `.env` file. For example: `APP_THEME=my_theme`.
@@ -50,6 +52,23 @@ This method allows you to register a custom social authentication driver within
*See "Custom Socialite Service Example" below.*
### `Theme::registerCommand`
This method allows you to register a custom command which can then be used via the artisan console.
**Arguments**
- string $driverName
- array $config
- string $socialiteHandler
**Example**
*See "Custom Command Registration Example" below for a more detailed example.*
```php
Theme::registerCommand(new SayHelloCommand());
```
## Available Events
All available events dispatched by BookStack are exposed as static properties on the `\BookStack\Theming\ThemeEvents` class, which can be found within the file `app/Theming/ThemeEvents.php` relative to your root BookStack folder. Alternatively, the events for the latest release can be [seen on GitHub here](https://github.com/BookStackApp/BookStack/blob/release/app/Theming/ThemeEvents.php).
@@ -77,6 +96,33 @@ Theme::listen(ThemeEvents::APP_BOOT, function($app) {
});
```
## Custom Command Registration Example
The logical theme system supports adding custom [artisan commands](https://laravel.com/docs/8.x/artisan) to BookStack.
These can be registered in your `functions.php` file by calling `Theme::registerCommand($command)`, where `$command` is an instance of `\Symfony\Component\Console\Command\Command`.
Below is an example of registering a command that could then be ran using `php artisan bookstack:meow` on the command line.
```php
<?php
use BookStack\Facades\Theme;
use Illuminate\Console\Command;
class MeowCommand extends Command
{
protected $signature = 'bookstack:meow';
protected $description = 'Say meow on the command line';
public function handle()
{
$this->line('Meow there!');
}
}
Theme::registerCommand(new MeowCommand);
```
## Custom Socialite Service Example
The below shows an example of adding a custom reddit socialite service to BookStack.

View File

@@ -6,6 +6,8 @@ This theme system itself is maintained and supported but usages of this system,
## Getting Started
*[Video Guide](https://www.youtube.com/watch?v=gLy_2GBse48)*
This makes use of the theme system. Create a folder for your theme within your BookStack `themes` directory. As an example we'll use `my_theme`, so we'd create a `themes/my_theme` folder.
You'll need to tell BookStack to use your theme via the `APP_THEME` option in your `.env` file. For example: `APP_THEME=my_theme`.
@@ -28,4 +30,4 @@ As an example, Say I wanted to change 'Search' to 'Find'; Within a `themes/<them
return [
'search' => 'find',
];
```
```

66
public/dist/app.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -34,13 +34,17 @@ Big thanks to these companies for supporting the project.
Note: Listed services are not tested, vetted nor supported by the official BookStack project in any manner.
[View all sponsors](https://github.com/sponsors/ssddanbrown).
#### Bronze Sponsors
#### Silver Sponsor
<table><tbody><tr>
<td><a href="https://www.diagrams.net/" target="_blank">
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
<img width="420" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
</a></td>
</tr></tbody></table>
#### Bronze Sponsor
<table><tbody><tr>
<td><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
</a></td>

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