Compare commits

...

99 Commits

Author SHA1 Message Date
Dan Brown
457adc1fee Updated version and assets for release v23.12 2023-12-29 12:16:07 +00:00
Dan Brown
e86a90967e Merge branch 'development' into release 2023-12-29 12:15:34 +00:00
Dan Brown
b191d8f99f Updated translator attribution before release v23.12 2023-12-29 12:08:39 +00:00
Dan Brown
c017f5bed1 Updated translations with latest Crowdin changes (#4658) 2023-12-28 17:49:38 +00:00
Dan Brown
5b1929a39a Languages: Added Finnish to language list 2023-12-28 15:24:51 +00:00
Dan Brown
02d94c8798 Permissions: Updated generation querying to be more efficient
Query of existing entity permissions during view permission generation
could cause timeouts or SQL placeholder limits due to massive whereOr
query generation, where an "or where" clause would be created for each
entity type/id combo involved, which could be all within 20 books.

This updates the query handling to use a query per type involved, with
no "or where"s, and to be chunked at large entity counts.

Also tweaked role-specific permission regen to chunk books at
half-previous rate to prevent such a large scope being involved on each
chunk.

For #4695
2023-12-23 13:35:57 +00:00
Dan Brown
88ee33ee49 Deps: Updated php depenencies via composer 2023-12-22 15:48:46 +00:00
Dan Brown
529f7bd1bc Merge pull request #4729 from BookStackApp/description_wysiwyg
Simple WYSIWYG for description fields and comments
2023-12-22 15:28:13 +00:00
Dan Brown
3668949705 Input WYSIWYG: Fixed up some dark mode elements 2023-12-22 15:16:06 +00:00
Dan Brown
7cd0629a75 Input WYSIWYG: Updated exports to handle HTML descriptions 2023-12-22 14:57:20 +00:00
Dan Brown
fb3cfaf7c7 Input WYSIWYG: Updated API examples to align with changes 2023-12-22 14:37:48 +00:00
Dan Brown
2a7a81e749 Input WYSIWYG: Updated API testing, fixed description set issue
Fixed issue where an existing description_html field would not be
updated via 'description' input.
2023-12-22 13:17:23 +00:00
Dan Brown
00ae04e0bd Input WYSIWYG: Updated API to show/accept html descriptions
Also aligned books, shelves and chapters to return description content
and some relations (where not breaking API) in create/update responses
also so that information can be seen direct from that input in a
request.

API docs and tests not yet updated to match.
2023-12-21 13:23:52 +00:00
Dan Brown
ed5d67e609 Input WYSIWYG: Aligned newline handling with old descriptions
To ensure consistenent behaviour before/after changes.
Added tests to cover.
2023-12-20 17:40:58 +00:00
Dan Brown
a21ca44633 Input WYSIWYG: Fixed existing tests, fixed empty description handling 2023-12-20 17:21:09 +00:00
Dan Brown
7fd6d5b2cc Input WYSIWYG: Updated tests, Added simple html limiting 2023-12-19 15:10:29 +00:00
Dan Brown
077b9709d4 Input WYSIWYG: Added testing for description references 2023-12-19 12:55:51 +00:00
Dan Brown
2fbed3919b Input WYSIWYG: Added dynamic options for entity selector popups
So that multiple elements on the page can share the same popup, with
different search options.
2023-12-19 12:09:57 +00:00
Dan Brown
c07aa056c2 Input WYSIWYG: Updated UpdateUrlCommand, Added chapter HTML display 2023-12-18 18:31:16 +00:00
Dan Brown
bc354e8b12 Input WYSIWYG: Updated reference link updating for descriptions 2023-12-18 18:12:36 +00:00
Dan Brown
307fae39c4 Input WYSIWYG: Added reference store & fetch handling
For book, shelves and chapters.
Made much of the existing handling generic to entity types.
Added new MixedEntityListLoader to help load lists somewhat efficiently.
Only manually tested so far.
2023-12-18 16:23:40 +00:00
Dan Brown
c622b785a9 Input WYSIWYG: Added description_html field, added store logic
Rolled out HTML editor field and store logic across all target entity
types. Cleaned up WYSIWYG input logic and design.
Cleaned up some injected classes while there.
2023-12-17 15:02:15 +00:00
Dan Brown
569542f0bb Input WYSIWYG: Added compontent and rough logic to book form
Just as a draft for prototyping and playing around to get things
started.
2023-12-16 14:48:35 +00:00
Dan Brown
fc2e8ed315 Merge pull request #4728 from BookStackApp/friendlier_buttons
Design: Updated buttons to be a bit friendlier
2023-12-16 14:04:57 +00:00
Dan Brown
0c4dd7874c Design: Updated buttons to be a bit friendlier
Old all-caps button design made them a bit angry, and kinda odd and
outdated. This updates them to use their original source text casing
(which may help for translation variations) while being a bit rounder
with a better defined shadow for outline buttons.
2023-12-16 14:03:12 +00:00
Dan Brown
7250671889 Merge pull request #4727 from BookStackApp/editor_video_alignment
WYSWIYG: Allowed video/embed alignment controls
2023-12-16 12:32:52 +00:00
Dan Brown
5395ca2f00 WYSWIYG: Allowed video/embed alignment controls
Required a lot of working around TinyMCE since it added a
preview/wrapper element in the editor which complicates things.
Added view new "fixes.js" file so large hacks to default TinyMCe
functionality are kept in one place.
2023-12-16 12:22:40 +00:00
Dan Brown
56d07f1909 Users API: Fixed sending invite when using form requests
- Cast send_invite value in cases where it might not have been a boolean,
  which occurs on non-JSON requests.
- Added test to cover.
- Updated API docs to mention and shown boolean usage.
2023-12-13 15:13:54 +00:00
Dan Brown
4896c4047f Merge pull request #4721 from BookStackApp/default-templates
Continued: Default book templates
2023-12-12 16:06:35 +00:00
Dan Brown
3af07addf6 Default templates: Fixed syntax for php8.0, added test
Null accessor is akward in php8.0 and throws warnings, so removed.
Added test to check template assingment handling on page delete.
2023-12-12 15:59:12 +00:00
Dan Brown
2f3806244c Default templates: Added permission checks to selector test 2023-12-12 15:41:56 +00:00
Dan Brown
2081a783f3 Default templates: Cleaned up ux, added case for added endpoint
Cleaned up and updated page picker a bit, allowing longer names to show,
clicking through to item without triggering popup, and updated to use
hidden attributes instead of styles.

Added phpunit tests to cover supporting entity-selector-templates
endpoint.
2023-12-12 15:38:09 +00:00
Dan Brown
d75eb06777 Default templates: Added tests to cover functionality
Included new helper in Test PermissionProvider to set app to public,
since that's a common test scenario.
2023-12-12 15:04:40 +00:00
Dan Brown
4017048555 Page Templates: Changed template field name, added API support 2023-12-12 12:14:00 +00:00
Dan Brown
7ebe7d4e58 Default templates: Added page picker and working forms
- Adapted existing page picker to be usable elsewhere.
- Added endpoint for getting templates for entity picker.
- Added search template filter to support above.
- Updated book save handling to check/validate submitted template.
  - Allows non-visible pages to flow through the save process, if not
    being changed.
- Updated page deletes to handle removal of default usage on books.
- Tweaked wording and form styles to suit.
- Updated migration to explicity reflect default value.
2023-12-11 15:58:27 +00:00
Dan Brown
d61f42a377 Default Templates: Started review and updates from PR code 2023-12-11 12:33:20 +00:00
Dan Brown
968bc8cdf3 Merge branch 'development' into default-templates 2023-12-11 11:41:43 +00:00
Dan Brown
c13fd2a9e6 PHPStan: Fixed larastan loading and address some level2 issues 2023-12-10 14:58:05 +00:00
Dan Brown
45ce7a7126 URL Handling: Removed referrer-based redirect handling
Swapped back handling to instead be pre-determined instead of being
based upon session/referrer which would cause inconsistent results when
referrer data was not available (redirect to app-loaded images/files).

To support, this adds a mechansism to provide a URL through request
data.

Also cleaned up some imports in code while making changes.
Closes #4656.
2023-12-10 12:37:21 +00:00
Dan Brown
11955e270c Depenencies: Updated NPM packages
Avoided updating markdown-it package to 14 for now since it would cause
bundle size to inflate. Don't think ESBuild is properly tree shaking
"entities" sub package which inflates size.
2023-12-09 10:49:28 +00:00
Dan Brown
33374524bf Dependencies: Updated composer PHP deps 2023-12-09 10:05:23 +00:00
Dan Brown
8cbaa3e27c SAML2: Fixed non-spec point of logout, Improved redirect location
This changes the point-of-logout to be within the initial part of the
SAML logout flow, as per 5.3.2 of the SAML spec, processing step 2.
This also improves the logout redirect handling to use the global
redirect suggestion so that auto-login handling is properly taken into
account.

Added tests to cover.
Manual testing performed against keycloak.
For #4713
2023-12-08 18:42:13 +00:00
Dan Brown
4c0b7f3123 Merge pull request #4714 from BookStackApp/oidc_logout
OIDC RP-Initiated logout
2023-12-07 18:00:32 +00:00
Dan Brown
7312300d53 OIDC: Update example env option to reflect correct default 2023-12-07 17:59:48 +00:00
Dan Brown
81d256aebd OIDC RP Logout: Fixed issues during testing
- Disabled by default due to strict rejection by auth systems.
- Fixed issue when autoloading logout URL, but not provided in
  autodiscovery response.
- Added proper handling for if the logout URL contains a query string
  already.
- Added extra tests to cover.
- Forced config endpoint to be used, if set as a string, instead of
  autodiscovery endpoint.
2023-12-07 17:45:17 +00:00
Dan Brown
a72e0fee70 Tests: Fixed debug test to work with social class changes 2023-12-06 16:57:15 +00:00
Dan Brown
f32cfb4292 OIDC RP Logout: Added autodiscovery support and test cases 2023-12-06 16:41:50 +00:00
Dan Brown
bba7dcce49 Auth: Refactored OIDC RP-logout PR code, Extracted logout
Extracted logout to the login service so the logic can be shared instead
of re-implemented at each stage. For this, the SocialAuthService was
split so the driver management is in its own class, so it can be used
elsewhere without use (or circular dependencies) of the
SocialAuthService.

During review of #4467
2023-12-06 13:49:53 +00:00
Dan Brown
cc10d1ddfc Merge branch 'fix/oidc-logout' into development 2023-12-06 12:14:43 +00:00
Dan Brown
0254527bd9 RTL: Made a range of fixes & improvments for RTL text
- Updated HTML exports to have auto direction to properly react to RTL
  text when in the content.
- Fixed RTL spacing issues in new editor design changes.
- Fixed pointer arrow being angled wrong on RTL languages.

Related to #4645
2023-12-05 18:53:48 +00:00
Dan Brown
11853361b0 SAML2: Included parsed groups in dump data
Updated code style of class while there.
Removed redundant check and string translation used.

For #4706
2023-12-03 19:36:03 +00:00
Dan Brown
596f7314cd Merge branch 'v23-10' into development 2023-12-03 18:57:07 +00:00
Dan Brown
1011d61713 Merge pull request #4688 from BookStackApp/include-parser
New include tag parser
2023-11-27 21:54:18 +00:00
Dan Brown
652d5417bf Includes: Added back support for parse theme event
Managed to do this in an API-compatible way although resuling output may
differ due to new dom handling in general, although user content is used
inline to remain as comptable as possible.
2023-11-27 21:39:43 +00:00
Dan Brown
b569827114 Includes: Added ID de-duplicating and more thorough clean-up 2023-11-27 20:16:27 +00:00
Dan Brown
71c93c8878 Includes: Switched page to new system
- Added mulit-level depth parsing.
- Updating usage of HTML doc in page content to be efficient.
- Removed now redundant PageContentTest cases.
- Made some include system fixes based upon testing.
2023-11-27 19:54:47 +00:00
Dan Brown
4874dc1304 Includes: Updated logic regarding parent block els, added tests
Expanded tests with many more cases, and added fixes for failed
scenarios.
Updated logic to specifically handling parent <p> tags, and now assume
compatibility with parent block types elswhere to allow use in a
variety of scenarios (td, details, blockquote etc...).
2023-11-25 17:32:00 +00:00
Dan Brown
c88eb729a4 Includes: Added block-level handling to new include system
Implements block promoting to body (including position choosing based
upon likely tag position within parent) and block splitting where we're
only a single depth down from the body child.
2023-11-24 23:39:16 +00:00
Dan Brown
75936454cc Includes: Developed to get new system working with inline includes
Adds logic for locating and splitting text nodes.
Adds specific classes to offload tag/content specific logic.
2023-11-23 14:29:07 +00:00
Dan Brown
04d21c8a97 Includes: Started foundations for new include tag parser 2023-11-22 22:14:28 +00:00
Dan Brown
5d08f7cf14 Updated version and assets for release v23.10.4 2023-11-20 14:19:46 +00:00
Dan Brown
8744eb2d62 Merge branch 'v23-10' into release 2023-11-20 14:02:23 +00:00
Dan Brown
15d7161428 Images: Prevented base64 extraction without permission
Also added content sniffing as an extra check.
Added tests to cover.
2023-11-20 13:32:31 +00:00
Dan Brown
9b1f820596 Images: Forced intervention loading via specific method
Updated image loading for intervention library to be via a specific
'initFromBinary' method to avoid being overly accepting of input types
and mechansisms.

For CVE-2023-6199
2023-11-19 16:34:29 +00:00
Dan Brown
2fb873f7ef Favicon: Moved resizing to specific resizer class 2023-11-19 15:57:19 +00:00
Dan Brown
22a9cf1e48 LogicalTheme: Added events for registering web routes
Added to allow easier registration of routes.
Added for normal web and authed routes.
Included testing to cover.
2023-11-17 13:45:57 +00:00
Dan Brown
37a17e858a HTML: Tweaked output from full HtmlDocument
Saves specifically the document element on output to HTML, since this
results in just the outer HTML being saved while not including the extra
XML tags which would show up before with the changes to force utf8
usage.
2023-11-14 17:23:05 +00:00
Dan Brown
eab9c1081e Merge pull request #4673 from BookStackApp/html_doc_alignment
HTML: Aligned and standardised DOMDocument usage
2023-11-14 17:22:30 +00:00
Dan Brown
db7b11fe93 HTML: Aligned and standardised DOMDocument usage
Adds a thin wrapper for DOMDocument to simplify and align usage within
all areas of BookStack.
Also means we move away from old depreacted mb_convert_encoding usage.

Closes #4638
2023-11-14 15:46:32 +00:00
Dan Brown
3a6f50e668 Merge pull request #4661 from BookStackApp/tinymce_update
WYSIWYG: Updated TinyMCE from 6.5.1 to 6.7.2
2023-11-14 13:15:32 +00:00
Dan Brown
76417efd6f Merge branch 'Man-in-Black-patch-1' into development 2023-11-14 10:40:30 +00:00
Dan Brown
d41fd7a8dd Notifications: Review of PR to include path path #4629
- Merged book and chapter name items to a single page path list item
  which has links to parent page/chapter.
- Added permission filtering to page path elements.
- Added page path to also be on comment notifications.
- Updated testing to cover.
- Added new Message Line objects to support.

Done during review of #4629
2023-11-14 10:38:34 +00:00
Sascha
65ac197be4 Added book name to the mail template
added book name

synced with actual file from dev branch

added book name

add book name

added book name

extended with chaptername

extended with chapter name

Update PageUpdateNotification.php

Update notifications.php

Update notifications.php

Update notifications.php

correction of chapter syntax

correction of chapter syntax
2023-11-14 10:38:34 +00:00
Dan Brown
bff1f502bb JS: Removed random extra import 2023-11-09 13:36:00 +00:00
Dan Brown
f8ebbb7553 WYSIWYG: Updated TinyMCE from 6.5.1 to 6.7.2 2023-11-09 13:34:00 +00:00
Dan Brown
d8383cfa80 Updated version and assets for release v23.10.2 2023-11-07 15:22:34 +00:00
Dan Brown
4626278447 Merge branch 'development' into release 2023-11-07 15:22:11 +00:00
Dan Brown
48f115291a Updated translator attribution before release v23.10.2 2023-11-07 15:12:15 +00:00
Dan Brown
6cd38a8ace Merge branch 'development' of github.com:BookStackApp/BookStack into development 2023-11-07 15:09:54 +00:00
Dan Brown
fa6ac211b6 Dropdowns: Fixed bad direction logic, added dynmaic height
Changes since adding notifications would cause direction to be assessed
upon max height of 80vh, which caused large dropdowns like the audit log
dropdown to drop up and/or go offscreen.
This restores the default assessment of 500px, and adds dynamic
max-height adjustment to provide more room for large dropdowns.

For #4652
2023-11-07 15:07:11 +00:00
Dan Brown
1310db19ca Updated translations with latest Crowdin changes (#4643) 2023-11-07 14:40:53 +00:00
Dan Brown
ea0469e61a PWA: Prevent passing credentials to avoid redirection issues
For #4649
More of a patch around the issue for now.
Have opened #4656 to properly address.
2023-11-07 14:33:37 +00:00
Dan Brown
c61af9c22b Updated version and assets for release v23.10.1 2023-11-02 14:44:53 +00:00
Dan Brown
72521d0906 Merge branch 'development' into release 2023-11-02 14:35:49 +00:00
Dan Brown
889b0dae3b Updated translations with latest Crowdin changes (#4631) 2023-11-02 14:30:34 +00:00
Dan Brown
48bda115aa Langs: Enabled Nynorsk option, updated translator attribution 2023-11-02 14:17:56 +00:00
Dan Brown
9dd05b8751 MD Editor: Fixed lack of toolbar BG when in fullscreen
For #4641
2023-11-02 12:41:07 +00:00
Dan Brown
02d140120a Editor toolbox: Updated tabs to use link color
Change due to link color being more suitable in this case since it's not
specifically a block with light text which is what app color is suited
for.
Specifically better for dark mode when a dark app color is used.

For #4630
2023-11-02 12:34:57 +00:00
Dan Brown
38ac3c959b Page JS: Improved block jumping and highlighting
- Updated anchor scroll change to open up details blocks if the target
  exists within.
- Updated highlighting and animation implementation to fix hardly visible highlighting.
- Removed old, now unused, handing of CM instances in details blocks.

Related to #4637.
2023-11-01 18:49:47 +00:00
Dan Brown
324e403ae5 JS Events: Added CM pre/post init events
To allow hacking of all CodeMirror instances.
Closes #4639.
2023-11-01 17:56:52 +00:00
Dan Brown
fce7190257 Testing: Added PHP8.3 support
Also fixed text which could through deprecation notice due to not having
a properly formed comment in use.
For #4633
2023-10-31 15:52:01 +00:00
Dan Brown
c640db8434 Readme: Updated sponsorship links and language contribution info
- Updated sponsor text since it only mentioned GitHub, nothing else.
- Updated translation contribution info to dissuade code-based
  contributions due to issues with conflicts/sync.
2023-10-30 17:13:39 +00:00
joancyho
a0942ef441 Fixed OIDC Logout 2023-08-29 14:58:57 +08:00
joancyho
6b55104ecb Fixed OIDC Logout 2023-08-29 13:07:21 +08:00
Lennert Daniels
ac519b3009 Guest create page: name field autofocus 2022-12-02 18:44:17 +01:00
Lennert Daniels
ec3b06d83f Add notice to Page delete confirmation when in use as a template 2022-12-02 18:43:51 +01:00
Lennert Daniels
99ae759eff Prefill new pages with book's default template 2022-12-02 18:42:58 +01:00
Lennert Daniels
1dbc3588cf Add default_template as Book setting 2022-12-02 18:41:59 +01:00
Lennert Daniels
3599a962a3 search-box-cancel placement 2022-12-02 13:10:57 +01:00
502 changed files with 8139 additions and 5071 deletions

View File

@@ -273,6 +273,7 @@ OIDC_USER_TO_GROUPS=false
OIDC_GROUPS_CLAIM=groups
OIDC_REMOVE_FROM_GROUPS=false
OIDC_EXTERNAL_ID_CLAIM=sub
OIDC_END_SESSION_ENDPOINT=false
# Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option

View File

@@ -177,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: French; Dutch; Turkish;
REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -348,7 +348,7 @@ robing29 :: German
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
Igor V Belousov (biv) :: Russian
David Bauer (davbauer) :: German
Guttorm Hveem (guttormhveem) :: Norwegian Bokmal; Norwegian Nynorsk
Guttorm Hveem (guttormhveem) :: Norwegian Nynorsk; Norwegian Bokmal
Minh Giang Truong (minhgiang1204) :: Vietnamese
Ioannis Ioannides (i.ioannides) :: Greek
Vadim (vadrozh) :: Russian
@@ -357,9 +357,9 @@ Paulo Henrique (paulohsantos114) :: Portuguese, Brazilian
Dženan (Dzenan) :: Swedish
Péter Péli (peter.peli) :: Hungarian
TWME :: Chinese Traditional
Sascha (Man-in-Black) :: German
Sascha (Man-in-Black) :: German; German Informal
Mohammadreza Madadi (madadi.efl) :: Persian
Konstantin Kovacheli (kkovacheli) :: Ukrainian
Konstantin (kkovacheli) :: Ukrainian; Russian
link1183 :: French
Renan (rfpe) :: Portuguese, Brazilian
Lowkey (bbsweb) :: Chinese Simplified
@@ -367,3 +367,22 @@ ZZnOB (zznobzz) :: Russian
rupus :: Swedish
developernecsys :: Norwegian Nynorsk
xuan LI (xuanli233) :: Chinese Simplified
LameeQS :: Latvian
Sorin T. (trimbitassorin) :: Romanian
poesty :: Chinese Simplified
balmag :: Hungarian
Antti-Jussi Nygård (ajnyga) :: Finnish
Eduard Ereza Martínez (Ereza) :: Catalan
Jabir Lang (amar.almrad) :: Arabic
Jaroslav Koblizek (foretix) :: Czech; French
Wiktor Adamczyk (adamczyk.wiktor) :: Polish
Abdulmajeed Alshuaibi (4Majeed) :: Arabic
NotSmartZakk :: Czech
HyoungMin Lee (ddokkaebi) :: Korean
Dasferco :: Chinese Simplified
Marcus Teräs (mteras) :: Finnish
Serkan Yardim (serkanzz) :: Turkish
Y (cnsr) :: Ukrainian
ZY ZV (vy0b0x) :: Chinese Simplified
diegobenitez :: Spanish
Marc Hagen (MarcHagen) :: Dutch

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['8.0', '8.1', '8.2']
php: ['8.0', '8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v1

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['8.0', '8.1', '8.2']
php: ['8.0', '8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v1

3
.gitignore vendored
View File

@@ -29,4 +29,5 @@ webpack-stats.json
.phpunit.result.cache
.DS_Store
phpstan.neon
esbuild-meta.json
esbuild-meta.json
.phpactor.json

View File

@@ -9,11 +9,6 @@ use Illuminate\Support\Facades\Password;
class ForgotPasswordController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
@@ -30,10 +25,6 @@ class ForgotPasswordController extends Controller
/**
* Send a reset link to the given user.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\RedirectResponse
*/
public function sendResetLinkEmail(Request $request)
{
@@ -56,13 +47,13 @@ class ForgotPasswordController extends Controller
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
$this->showSuccessNotification($message);
return back()->with('status', trans($response));
return redirect('/password/email')->with('status', trans($response));
}
// If an error was returned by the password broker, we will get this message
// translated so we can notify a user of the problem. We'll redirect back
// to where the users came from so they can attempt this process again.
return back()->withErrors(
return redirect('/password/email')->withErrors(
['email' => trans($response)]
);
}

View File

@@ -3,34 +3,26 @@
namespace BookStack\Access\Controllers;
use BookStack\Access\LoginService;
use BookStack\Access\SocialAuthService;
use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Facades\Activity;
use BookStack\Http\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
use ThrottlesLogins;
protected SocialAuthService $socialAuthService;
protected LoginService $loginService;
/**
* Create a new controller instance.
*/
public function __construct(SocialAuthService $socialAuthService, LoginService $loginService)
{
public function __construct(
protected SocialDriverManager $socialDriverManager,
protected LoginService $loginService,
) {
$this->middleware('guest', ['only' => ['getLogin', 'login']]);
$this->middleware('guard:standard,ldap', ['only' => ['login']]);
$this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]);
$this->socialAuthService = $socialAuthService;
$this->loginService = $loginService;
}
/**
@@ -38,7 +30,7 @@ class LoginController extends Controller
*/
public function getLogin(Request $request)
{
$socialDrivers = $this->socialAuthService->getActiveDrivers();
$socialDrivers = $this->socialDriverManager->getActive();
$authMethod = config('auth.method');
$preventInitiation = $request->get('prevent_auto_init') === 'true';
@@ -52,7 +44,7 @@ class LoginController extends Controller
// Store the previous location for redirect after login
$this->updateIntendedFromPrevious();
if (!$preventInitiation && $this->shouldAutoInitiate()) {
if (!$preventInitiation && $this->loginService->shouldAutoInitiate()) {
return view('auth.login-initiate', [
'authMethod' => $authMethod,
]);
@@ -101,15 +93,9 @@ class LoginController extends Controller
/**
* Logout user and perform subsequent redirect.
*/
public function logout(Request $request)
public function logout()
{
Auth::guard()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
return redirect($redirectUri);
return redirect($this->loginService->logout());
}
/**
@@ -200,7 +186,7 @@ class LoginController extends Controller
{
// Store the previous location for redirect after login
$previous = url()->previous('');
$isPreviousFromInstance = (strpos($previous, url('/')) === 0);
$isPreviousFromInstance = str_starts_with($previous, url('/'));
if (!$previous || !setting('app-public') || !$isPreviousFromInstance) {
return;
}
@@ -211,23 +197,11 @@ class LoginController extends Controller
];
foreach ($ignorePrefixList as $ignorePrefix) {
if (strpos($previous, url($ignorePrefix)) === 0) {
if (str_starts_with($previous, url($ignorePrefix))) {
return;
}
}
redirect()->setIntendedUrl($previous);
}
/**
* Check if login auto-initiate should be valid based upon authentication config.
*/
protected function shouldAutoInitiate(): bool
{
$socialDrivers = $this->socialAuthService->getActiveDrivers();
$authMethod = config('auth.method');
$autoRedirect = config('auth.auto_initiate');
return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
}
}

View File

@@ -11,9 +11,6 @@ class OidcController extends Controller
{
protected OidcService $oidcService;
/**
* OpenIdController constructor.
*/
public function __construct(OidcService $oidcService)
{
$this->oidcService = $oidcService;
@@ -63,4 +60,12 @@ class OidcController extends Controller
return redirect()->intended();
}
/**
* Log the user out then start the OIDC RP-initiated logout process.
*/
public function logout()
{
return redirect($this->oidcService->logout());
}
}

View File

@@ -4,7 +4,7 @@ namespace BookStack\Access\Controllers;
use BookStack\Access\LoginService;
use BookStack\Access\RegistrationService;
use BookStack\Access\SocialAuthService;
use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Http\Controller;
@@ -15,7 +15,7 @@ use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
protected SocialAuthService $socialAuthService;
protected SocialDriverManager $socialDriverManager;
protected RegistrationService $registrationService;
protected LoginService $loginService;
@@ -23,14 +23,14 @@ class RegisterController extends Controller
* Create a new controller instance.
*/
public function __construct(
SocialAuthService $socialAuthService,
SocialDriverManager $socialDriverManager,
RegistrationService $registrationService,
LoginService $loginService
) {
$this->middleware('guest');
$this->middleware('guard:standard');
$this->socialAuthService = $socialAuthService;
$this->socialDriverManager = $socialDriverManager;
$this->registrationService = $registrationService;
$this->loginService = $loginService;
}
@@ -43,7 +43,7 @@ class RegisterController extends Controller
public function getRegister()
{
$this->registrationService->ensureRegistrationAllowed();
$socialDrivers = $this->socialAuthService->getActiveDrivers();
$socialDrivers = $this->socialDriverManager->getActive();
return view('auth.register', [
'socialDrivers' => $socialDrivers,

View File

@@ -66,7 +66,7 @@ class ResetPasswordController extends Controller
// redirect them back to where they came from with their error message.
return $response === Password::PASSWORD_RESET
? $this->sendResetResponse()
: $this->sendResetFailedResponse($request, $response);
: $this->sendResetFailedResponse($request, $response, $request->get('token'));
}
/**
@@ -83,7 +83,7 @@ class ResetPasswordController extends Controller
/**
* Get the response for a failed password reset.
*/
protected function sendResetFailedResponse(Request $request, string $response): RedirectResponse
protected function sendResetFailedResponse(Request $request, string $response, string $token): RedirectResponse
{
// We show invalid users as invalid tokens as to not leak what
// users may exist in the system.
@@ -91,7 +91,7 @@ class ResetPasswordController extends Controller
$response = Password::INVALID_TOKEN;
}
return redirect()->back()
return redirect("/password/reset/{$token}")
->withInput($request->only('email'))
->withErrors(['email' => trans($response)]);
}

View File

@@ -9,14 +9,9 @@ use Illuminate\Support\Str;
class Saml2Controller extends Controller
{
protected Saml2Service $samlService;
/**
* Saml2Controller constructor.
*/
public function __construct(Saml2Service $samlService)
{
$this->samlService = $samlService;
public function __construct(
protected Saml2Service $samlService
) {
$this->middleware('guard:saml2');
}
@@ -36,7 +31,12 @@ class Saml2Controller extends Controller
*/
public function logout()
{
$logoutDetails = $this->samlService->logout(auth()->user());
$user = user();
if ($user->isGuest()) {
return redirect('/login');
}
$logoutDetails = $this->samlService->logout($user);
if ($logoutDetails['id']) {
session()->flash('saml2_logout_request_id', $logoutDetails['id']);
@@ -64,7 +64,7 @@ class Saml2Controller extends Controller
public function sls()
{
$requestId = session()->pull('saml2_logout_request_id', null);
$redirect = $this->samlService->processSlsResponse($requestId) ?? '/';
$redirect = $this->samlService->processSlsResponse($requestId);
return redirect($redirect);
}

View File

@@ -79,7 +79,7 @@ class SocialController extends Controller
try {
return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser);
} catch (SocialSignInAccountNotUsed $exception) {
if ($this->socialAuthService->driverAutoRegisterEnabled($socialDriver)) {
if ($this->socialAuthService->drivers()->isAutoRegisterEnabled($socialDriver)) {
return $this->socialRegisterCallback($socialDriver, $socialUser);
}
@@ -91,7 +91,7 @@ class SocialController extends Controller
return $this->socialRegisterCallback($socialDriver, $socialUser);
}
return redirect()->back();
return redirect('/');
}
/**
@@ -114,7 +114,7 @@ class SocialController extends Controller
{
$socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
$socialAccount = $this->socialAuthService->newSocialAccount($socialDriver, $socialUser);
$emailVerified = $this->socialAuthService->driverAutoConfirmEmailEnabled($socialDriver);
$emailVerified = $this->socialAuthService->drivers()->isAutoConfirmEmailEnabled($socialDriver);
// Create an array of the user data to create a new user instance
$userData = [

View File

@@ -16,13 +16,11 @@ class LoginService
{
protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
protected $mfaSession;
protected $emailConfirmationService;
public function __construct(MfaSession $mfaSession, EmailConfirmationService $emailConfirmationService)
{
$this->mfaSession = $mfaSession;
$this->emailConfirmationService = $emailConfirmationService;
public function __construct(
protected MfaSession $mfaSession,
protected EmailConfirmationService $emailConfirmationService,
protected SocialDriverManager $socialDriverManager,
) {
}
/**
@@ -163,4 +161,33 @@ class LoginService
return $result;
}
/**
* Logs the current user out of the application.
* Returns an app post-redirect path.
*/
public function logout(): string
{
auth()->logout();
session()->invalidate();
session()->regenerateToken();
return $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
}
/**
* Check if login auto-initiate should be active based upon authentication config.
*/
public function shouldAutoInitiate(): bool
{
$autoRedirect = config('auth.auto_initiate');
if (!$autoRedirect) {
return false;
}
$socialDrivers = $this->socialDriverManager->getActive();
$authMethod = config('auth.method');
return count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
}
}

View File

@@ -21,6 +21,7 @@ class OidcProviderSettings
public ?string $redirectUri;
public ?string $authorizationEndpoint;
public ?string $tokenEndpoint;
public ?string $endSessionEndpoint;
/**
* @var string[]|array[]
@@ -132,6 +133,10 @@ class OidcProviderSettings
$discoveredSettings['keys'] = $this->filterKeys($keys);
}
if (!empty($result['end_session_endpoint'])) {
$discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
}
return $discoveredSettings;
}

View File

@@ -84,6 +84,7 @@ class OidcService
'redirectUri' => url('/oidc/callback'),
'authorizationEndpoint' => $config['authorization_endpoint'],
'tokenEndpoint' => $config['token_endpoint'],
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
]);
// Use keys if configured
@@ -100,6 +101,14 @@ class OidcService
}
}
// Prevent use of RP-initiated logout if specifically disabled
// Or force use of a URL if specifically set.
if ($config['end_session_endpoint'] === false) {
$settings->endSessionEndpoint = null;
} else if (is_string($config['end_session_endpoint'])) {
$settings->endSessionEndpoint = $config['end_session_endpoint'];
}
$settings->validate();
return $settings;
@@ -217,6 +226,8 @@ class OidcService
$settings->keys,
);
session()->put("oidc_id_token", $idTokenText);
$returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [
'access_token' => $accessToken->getToken(),
'expires_in' => $accessToken->getExpires(),
@@ -284,4 +295,30 @@ class OidcService
{
return $this->config()['user_to_groups'] !== false;
}
/**
* Start the RP-initiated logout flow if active, otherwise start a standard logout flow.
* Returns a post-app-logout redirect URL.
* Reference: https://openid.net/specs/openid-connect-rpinitiated-1_0.html
* @throws OidcException
*/
public function logout(): string
{
$oidcToken = session()->pull("oidc_id_token");
$defaultLogoutUrl = url($this->loginService->logout());
$oidcSettings = $this->getProviderSettings();
if (!$oidcSettings->endSessionEndpoint) {
return $defaultLogoutUrl;
}
$endpointParams = [
'id_token_hint' => $oidcToken,
'post_logout_redirect_uri' => $defaultLogoutUrl,
];
$joiner = str_contains($oidcSettings->endSessionEndpoint, '?') ? '&' : '?';
return $oidcSettings->endSessionEndpoint . $joiner . http_build_query($endpointParams);
}
}

View File

@@ -21,19 +21,13 @@ use OneLogin\Saml2\ValidationError;
class Saml2Service
{
protected array $config;
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected GroupSyncService $groupSyncService;
public function __construct(
RegistrationService $registrationService,
LoginService $loginService,
GroupSyncService $groupSyncService
protected RegistrationService $registrationService,
protected LoginService $loginService,
protected GroupSyncService $groupSyncService
) {
$this->config = config('saml2');
$this->registrationService = $registrationService;
$this->loginService = $loginService;
$this->groupSyncService = $groupSyncService;
}
/**
@@ -54,20 +48,23 @@ class Saml2Service
/**
* Initiate a logout flow.
* Returns the SAML2 request ID, and the URL to redirect the user to.
*
* @throws Error
* @returns array{url: string, id: ?string}
*/
public function logout(User $user): array
{
$toolKit = $this->getToolkit();
$returnRoute = url('/');
$sessionIndex = session()->get('saml2_session_index');
$returnUrl = url($this->loginService->logout());
try {
$url = $toolKit->logout(
$returnRoute,
$returnUrl,
[],
$user->email,
session()->get('saml2_session_index'),
$sessionIndex,
true,
Constants::NAMEID_EMAIL_ADDRESS
);
@@ -77,8 +74,7 @@ class Saml2Service
throw $error;
}
$this->actionLogout();
$url = '/';
$url = $returnUrl;
$id = null;
}
@@ -128,7 +124,7 @@ class Saml2Service
*
* @throws Error
*/
public function processSlsResponse(?string $requestId): ?string
public function processSlsResponse(?string $requestId): string
{
$toolkit = $this->getToolkit();
@@ -137,7 +133,7 @@ class Saml2Service
// value so that the exact encoding format is matched when checking the signature.
// This is primarily due to ADFS encoding query params with lowercase percent encoding while
// PHP (And most other sensible providers) standardise on uppercase.
$redirect = $toolkit->processSLO(true, $requestId, true, null, true);
$samlRedirect = $toolkit->processSLO(true, $requestId, true, null, true);
$errors = $toolkit->getErrors();
if (!empty($errors)) {
@@ -146,18 +142,9 @@ class Saml2Service
);
}
$this->actionLogout();
$defaultBookStackRedirect = $this->loginService->logout();
return $redirect;
}
/**
* Do the required actions to log a user out.
*/
protected function actionLogout()
{
auth()->logout();
session()->invalidate();
return $samlRedirect ?? $defaultBookStackRedirect;
}
/**
@@ -357,6 +344,10 @@ class Saml2Service
$userDetails = $this->getUserDetails($samlID, $samlAttributes);
$isLoggedIn = auth()->check();
if ($this->shouldSyncGroups()) {
$userDetails['groups'] = $this->getUserGroups($samlAttributes);
}
if ($this->config['dump_user_details']) {
throw new JsonDebugException([
'id_from_idp' => $samlID,
@@ -379,13 +370,8 @@ class Saml2Service
$userDetails['external_id']
);
if ($user === null) {
throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
}
if ($this->shouldSyncGroups()) {
$groups = $this->getUserGroups($samlAttributes);
$this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']);
$this->groupSyncService->syncUserWithFoundGroups($user, $userDetails['groups'], $this->config['remove_from_groups']);
}
$this->loginService->login($user, 'saml2');

View File

@@ -2,69 +2,24 @@
namespace BookStack\Access;
use BookStack\Auth\Access\handler;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Event;
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;
class SocialAuthService
{
/**
* The core socialite library used.
*
* @var Socialite
*/
protected $socialite;
/**
* @var LoginService
*/
protected $loginService;
/**
* The default built-in social drivers we support.
*
* @var string[]
*/
protected $validSocialDrivers = [
'google',
'github',
'facebook',
'slack',
'twitter',
'azure',
'okta',
'gitlab',
'twitch',
'discord',
];
/**
* Callbacks to run when configuring a social driver
* for an initial redirect action.
* Array is keyed by social driver name.
* Callbacks are passed an instance of the driver.
*
* @var array<string, callable>
*/
protected $configureForRedirectCallbacks = [];
/**
* SocialAuthService constructor.
*/
public function __construct(Socialite $socialite, LoginService $loginService)
{
$this->socialite = $socialite;
$this->loginService = $loginService;
public function __construct(
protected Socialite $socialite,
protected LoginService $loginService,
protected SocialDriverManager $driverManager,
) {
}
/**
@@ -74,9 +29,10 @@ class SocialAuthService
*/
public function startLogIn(string $socialDriver): RedirectResponse
{
$driver = $this->validateDriver($socialDriver);
$socialDriver = trim(strtolower($socialDriver));
$this->driverManager->ensureDriverActive($socialDriver);
return $this->getDriverForRedirect($driver)->redirect();
return $this->getDriverForRedirect($socialDriver)->redirect();
}
/**
@@ -86,9 +42,10 @@ class SocialAuthService
*/
public function startRegister(string $socialDriver): RedirectResponse
{
$driver = $this->validateDriver($socialDriver);
$socialDriver = trim(strtolower($socialDriver));
$this->driverManager->ensureDriverActive($socialDriver);
return $this->getDriverForRedirect($driver)->redirect();
return $this->getDriverForRedirect($socialDriver)->redirect();
}
/**
@@ -119,9 +76,10 @@ class SocialAuthService
*/
public function getSocialUser(string $socialDriver): SocialUser
{
$driver = $this->validateDriver($socialDriver);
$socialDriver = trim(strtolower($socialDriver));
$this->driverManager->ensureDriverActive($socialDriver);
return $this->socialite->driver($driver)->user();
return $this->socialite->driver($socialDriver)->user();
}
/**
@@ -131,6 +89,7 @@ class SocialAuthService
*/
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
{
$socialDriver = trim(strtolower($socialDriver));
$socialId = $socialUser->getId();
// Get any attached social accounts or users
@@ -181,76 +140,11 @@ class SocialAuthService
}
/**
* Ensure the social driver is correct and supported.
*
* @throws SocialDriverNotConfigured
* Get the social driver manager used by this service.
*/
protected function validateDriver(string $socialDriver): string
public function drivers(): SocialDriverManager
{
$driver = trim(strtolower($socialDriver));
if (!in_array($driver, $this->validSocialDrivers)) {
abort(404, trans('errors.social_driver_not_found'));
}
if (!$this->checkDriverConfigured($driver)) {
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($socialDriver)]));
}
return $driver;
}
/**
* Check a social driver has been configured correctly.
*/
protected function checkDriverConfigured(string $driver): bool
{
$lowerName = strtolower($driver);
$configPrefix = 'services.' . $lowerName . '.';
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
return !in_array(false, $config) && !in_array(null, $config);
}
/**
* Gets the names of the active social drivers.
* @returns array<string, string>
*/
public function getActiveDrivers(): array
{
$activeDrivers = [];
foreach ($this->validSocialDrivers as $driverKey) {
if ($this->checkDriverConfigured($driverKey)) {
$activeDrivers[$driverKey] = $this->getDriverName($driverKey);
}
}
return $activeDrivers;
}
/**
* Get the presentational name for a driver.
*/
public function getDriverName(string $driver): string
{
return config('services.' . strtolower($driver) . '.name');
}
/**
* Check if the current config for the given driver allows auto-registration.
*/
public function driverAutoRegisterEnabled(string $driver): bool
{
return config('services.' . strtolower($driver) . '.auto_register') === true;
}
/**
* Check if the current config for the given driver allow email address auto-confirmation.
*/
public function driverAutoConfirmEmailEnabled(string $driver): bool
{
return config('services.' . strtolower($driver) . '.auto_confirm') === true;
return $this->driverManager;
}
/**
@@ -284,33 +178,8 @@ class SocialAuthService
$driver->with(['prompt' => 'select_account']);
}
if (isset($this->configureForRedirectCallbacks[$driverName])) {
$this->configureForRedirectCallbacks[$driverName]($driver);
}
$this->driverManager->getConfigureForRedirectCallback($driverName)($driver);
return $driver;
}
/**
* Add a custom socialite driver to be used.
* Driver name should be lower_snake_case.
* Config array should mirror the structure of a service
* within the `Config/services.php` file.
* Handler should be a Class@method handler to the SocialiteWasCalled event.
*/
public function addSocialDriver(
string $driverName,
array $config,
string $socialiteHandler,
callable $configureForRedirect = null
) {
$this->validSocialDrivers[] = $driverName;
config()->set('services.' . $driverName, $config);
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
if (!is_null($configureForRedirect)) {
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
}
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace BookStack\Access;
use BookStack\Exceptions\SocialDriverNotConfigured;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use SocialiteProviders\Manager\SocialiteWasCalled;
class SocialDriverManager
{
/**
* The default built-in social drivers we support.
*
* @var string[]
*/
protected array $validDrivers = [
'google',
'github',
'facebook',
'slack',
'twitter',
'azure',
'okta',
'gitlab',
'twitch',
'discord',
];
/**
* Callbacks to run when configuring a social driver
* for an initial redirect action.
* Array is keyed by social driver name.
* Callbacks are passed an instance of the driver.
*
* @var array<string, callable>
*/
protected array $configureForRedirectCallbacks = [];
/**
* Check if the current config for the given driver allows auto-registration.
*/
public function isAutoRegisterEnabled(string $driver): bool
{
return $this->getDriverConfigProperty($driver, 'auto_register') === true;
}
/**
* Check if the current config for the given driver allow email address auto-confirmation.
*/
public function isAutoConfirmEmailEnabled(string $driver): bool
{
return $this->getDriverConfigProperty($driver, 'auto_confirm') === true;
}
/**
* Gets the names of the active social drivers, keyed by driver id.
* @returns array<string, string>
*/
public function getActive(): array
{
$activeDrivers = [];
foreach ($this->validDrivers as $driverKey) {
if ($this->checkDriverConfigured($driverKey)) {
$activeDrivers[$driverKey] = $this->getName($driverKey);
}
}
return $activeDrivers;
}
/**
* Get the configure-for-redirect callback for the given driver.
* This is a callable that allows modification of the driver at redirect time.
* Commonly used to perform custom dynamic configuration where required.
* The callback is passed a \Laravel\Socialite\Contracts\Provider instance.
*/
public function getConfigureForRedirectCallback(string $driver): callable
{
return $this->configureForRedirectCallbacks[$driver] ?? (fn() => true);
}
/**
* Add a custom socialite driver to be used.
* Driver name should be lower_snake_case.
* Config array should mirror the structure of a service
* within the `Config/services.php` file.
* Handler should be a Class@method handler to the SocialiteWasCalled event.
*/
public function addSocialDriver(
string $driverName,
array $config,
string $socialiteHandler,
callable $configureForRedirect = null
) {
$this->validDrivers[] = $driverName;
config()->set('services.' . $driverName, $config);
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
if (!is_null($configureForRedirect)) {
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
}
}
/**
* Get the presentational name for a driver.
*/
protected function getName(string $driver): string
{
return $this->getDriverConfigProperty($driver, 'name') ?? '';
}
protected function getDriverConfigProperty(string $driver, string $property): mixed
{
return config("services.{$driver}.{$property}");
}
/**
* Ensure the social driver is correct and supported.
*
* @throws SocialDriverNotConfigured
*/
public function ensureDriverActive(string $driverName): void
{
if (!in_array($driverName, $this->validDrivers)) {
abort(404, trans('errors.social_driver_not_found'));
}
if (!$this->checkDriverConfigured($driverName)) {
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($driverName)]));
}
}
/**
* Check a social driver has been configured correctly.
*/
protected function checkDriverConfigured(string $driver): bool
{
$lowerName = strtolower($driver);
$configPrefix = 'services.' . $lowerName . '.';
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
return !in_array(false, $config) && !in_array(null, $config);
}
}

View File

@@ -2,9 +2,6 @@
namespace BookStack\Activity\Controllers;
use BookStack\Activity\Models\Favouritable;
use BookStack\App\Model;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
@@ -52,7 +49,7 @@ class FavouriteController extends Controller
'name' => $entity->name,
]));
return redirect()->back();
return redirect($entity->getUrl());
}
/**
@@ -70,6 +67,6 @@ class FavouriteController extends Controller
'name' => $entity->name,
]));
return redirect()->back();
return redirect($entity->getUrl());
}
}

View File

@@ -24,6 +24,6 @@ class WatchController extends Controller
$this->showSuccessNotification(trans('activities.watch_update_level_notification'));
return redirect()->back();
return redirect($watchable->getUrl());
}
}

View File

@@ -9,6 +9,7 @@ use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**

View File

@@ -0,0 +1,29 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
use BookStack\Entities\Models\Entity;
use Illuminate\Contracts\Support\Htmlable;
use Stringable;
/**
* A link to a specific entity in the system, with the text showing its name.
*/
class EntityLinkMessageLine implements Htmlable, Stringable
{
public function __construct(
protected Entity $entity,
protected int $nameLength = 120,
) {
}
public function toHtml(): string
{
return '<a href="' . e($this->entity->getUrl()) . '">' . e($this->entity->getShortName($this->nameLength)) . '</a>';
}
public function __toString(): string
{
return "{$this->entity->getShortName($this->nameLength)} ({$this->entity->getUrl()})";
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
use BookStack\Entities\Models\Entity;
use Illuminate\Contracts\Support\Htmlable;
use Stringable;
/**
* A link to a specific entity in the system, with the text showing its name.
*/
class EntityPathMessageLine implements Htmlable, Stringable
{
/**
* @var EntityLinkMessageLine[]
*/
protected array $entityLinks;
public function __construct(
protected array $entities
) {
$this->entityLinks = array_map(fn (Entity $entity) => new EntityLinkMessageLine($entity, 24), $this->entities);
}
public function toHtml(): string
{
$entityHtmls = array_map(fn (EntityLinkMessageLine $line) => $line->toHtml(), $this->entityLinks);
return implode(' &gt; ', $entityHtmls);
}
public function __toString(): string
{
return implode(' > ', $this->entityLinks);
}
}

View File

@@ -3,8 +3,12 @@
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\MessageParts\EntityPathMessageLine;
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
use BookStack\App\MailNotification;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Translation\LocaleDefinition;
use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable;
@@ -44,4 +48,20 @@ abstract class BaseActivityNotification extends MailNotification
$locale->trans('notifications.footer_reason_link'),
);
}
/**
* Build a line which provides the book > chapter path to a page.
* Takes into account visibility of these parent items.
* Returns null if no path items can be used.
*/
protected function buildPagePathLine(Page $page, User $notifiable): ?EntityPathMessageLine
{
$permissions = new PermissionApplicator($notifiable);
$path = array_filter([$page->book, $page->chapter], function (?Entity $entity) use ($permissions) {
return !is_null($entity) && $permissions->checkOwnableUserAccess($entity, 'view');
});
return empty($path) ? null : new EntityPathMessageLine($path);
}
}

View File

@@ -3,6 +3,7 @@
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
@@ -19,14 +20,17 @@ class CommentCreationNotification extends BaseActivityNotification
$locale = $notifiable->getLocale();
$listLines = array_filter([
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
$locale->trans('notifications.detail_commenter') => $this->user->name,
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
]);
return $this->newMailMessage($locale)
->subject($locale->trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()]))
->line($locale->trans('notifications.new_comment_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine([
$locale->trans('notifications.detail_page_name') => $page->name,
$locale->trans('notifications.detail_commenter') => $this->user->name,
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
]))
->line(new ListMessageLine($listLines))
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
->line($this->buildReasonFooterLine($locale));
}

View File

@@ -2,6 +2,7 @@
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
@@ -16,13 +17,16 @@ class PageCreationNotification extends BaseActivityNotification
$locale = $notifiable->getLocale();
$listLines = array_filter([
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
$locale->trans('notifications.detail_created_by') => $this->user->name,
]);
return $this->newMailMessage($locale)
->subject($locale->trans('notifications.new_page_subject', ['pageName' => $page->getShortName()]))
->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')], $locale))
->line(new ListMessageLine([
$locale->trans('notifications.detail_page_name') => $page->name,
$locale->trans('notifications.detail_created_by') => $this->user->name,
]))
->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine($listLines))
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
->line($this->buildReasonFooterLine($locale));
}

View File

@@ -2,6 +2,7 @@
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
@@ -16,13 +17,16 @@ class PageUpdateNotification extends BaseActivityNotification
$locale = $notifiable->getLocale();
$listLines = array_filter([
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
$locale->trans('notifications.detail_updated_by') => $this->user->name,
]);
return $this->newMailMessage($locale)
->subject($locale->trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()]))
->line($locale->trans('notifications.updated_page_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine([
$locale->trans('notifications.detail_page_name') => $page->name,
$locale->trans('notifications.detail_updated_by') => $this->user->name,
]))
->line(new ListMessageLine($listLines))
->line($locale->trans('notifications.updated_page_debounce'))
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
->line($this->buildReasonFooterLine($locale));

View File

@@ -2,7 +2,7 @@
namespace BookStack\App\Providers;
use BookStack\Access\SocialAuthService;
use BookStack\Access\SocialDriverManager;
use BookStack\Activity\Tools\ActivityLogger;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
@@ -36,7 +36,7 @@ class AppServiceProvider extends ServiceProvider
public $singletons = [
'activity' => ActivityLogger::class,
SettingService::class => SettingService::class,
SocialAuthService::class => SocialAuthService::class,
SocialDriverManager::class => SocialDriverManager::class,
CspService::class => CspService::class,
HttpRequestService::class => HttpRequestService::class,
];

View File

@@ -2,9 +2,12 @@
namespace BookStack\App\Providers;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
@@ -46,8 +49,15 @@ class RouteServiceProvider extends ServiceProvider
Route::group([
'middleware' => 'web',
'namespace' => $this->namespace,
], function ($router) {
], function (Router $router) {
require base_path('routes/web.php');
Theme::dispatch(ThemeEvents::ROUTES_REGISTER_WEB, $router);
});
Route::group([
'middleware' => ['web', 'auth'],
], function (Router $router) {
Theme::dispatch(ThemeEvents::ROUTES_REGISTER_WEB_AUTH, $router);
});
}

View File

@@ -4,6 +4,7 @@ namespace BookStack\App\Providers;
use BookStack\Theming\ThemeEvents;
use BookStack\Theming\ThemeService;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class ThemeServiceProvider extends ServiceProvider

View File

@@ -6,6 +6,11 @@ class PwaManifestBuilder
{
public function build(): array
{
// Note, while we attempt to use the user's preference here, the request to the manifest
// does not start a session, so we won't have current user context.
// This was attempted but removed since manifest calls could affect user session
// history tracking and back redirection.
// Context: https://github.com/BookStackApp/BookStack/issues/4649
$darkMode = (bool) setting()->getForCurrentUser('dark-mode-enabled');
$appName = setting('app-name');

View File

@@ -141,7 +141,6 @@ return [
// Third party service providers
Barryvdh\DomPDF\ServiceProvider::class,
Barryvdh\Snappy\ServiceProvider::class,
Intervention\Image\ImageServiceProvider::class,
SocialiteProviders\Manager\ServiceProvider::class,
// BookStack custom service providers
@@ -161,9 +160,6 @@ return [
// Laravel Packages
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
// Third Party
'ImageTool' => Intervention\Image\Facades\Image::class,
// Custom BookStack
'Activity' => BookStack\Facades\Activity::class,
'Theme' => BookStack\Facades\Theme::class,

View File

@@ -36,6 +36,12 @@ return [
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
// OIDC RP-Initiated Logout endpoint URL.
// A false value force-disables RP-Initiated Logout.
// A true value gets the URL from discovery, if active.
// A string value is used as the URL.
'end_session_endpoint' => env('OIDC_END_SESSION_ENDPOINT', false),
// Add extra scopes, upon those required, to the OIDC authentication request
// Multiple values can be provided comma seperated.
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
@@ -45,6 +51,6 @@ return [
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
// Attribute, within a OIDC ID token, to find group names within
'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'),
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
// When syncing groups, remove any groups that no longer match. Otherwise, sync only adds new groups.
'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false),
];

View File

@@ -34,7 +34,7 @@ class RegenerateReferencesCommand extends Command
DB::setDefaultConnection($this->option('database'));
}
$references->updateForAllPages();
$references->updateForAll();
DB::setDefaultConnection($connection);

View File

@@ -46,6 +46,9 @@ class UpdateUrlCommand extends Command
$columnsToUpdateByTable = [
'attachments' => ['path'],
'pages' => ['html', 'text', 'markdown'],
'chapters' => ['description_html'],
'books' => ['description_html'],
'bookshelves' => ['description_html'],
'images' => ['url'],
'settings' => ['value'],
'comments' => ['html', 'text'],

View File

@@ -14,11 +14,9 @@ use Illuminate\Validation\ValidationException;
class BookApiController extends ApiController
{
protected BookRepo $bookRepo;
public function __construct(BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
public function __construct(
protected BookRepo $bookRepo
) {
}
/**
@@ -47,7 +45,7 @@ class BookApiController extends ApiController
$book = $this->bookRepo->create($requestData);
return response()->json($book);
return response()->json($this->forJsonDisplay($book));
}
/**
@@ -58,7 +56,9 @@ class BookApiController extends ApiController
*/
public function read(string $id)
{
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
$book = Book::visible()->findOrFail($id);
$book = $this->forJsonDisplay($book);
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
$contents = (new BookContents($book))->getTree(true, false)->all();
$contentsApiData = (new ApiEntityListFormatter($contents))
@@ -89,7 +89,7 @@ class BookApiController extends ApiController
$requestData = $this->validate($request, $this->rules()['update']);
$book = $this->bookRepo->update($book, $requestData);
return response()->json($book);
return response()->json($this->forJsonDisplay($book));
}
/**
@@ -108,20 +108,36 @@ class BookApiController extends ApiController
return response('', 204);
}
protected function forJsonDisplay(Book $book): Book
{
$book = clone $book;
$book->unsetRelations()->refresh();
$book->load(['tags', 'cover']);
$book->makeVisible('description_html')
->setAttribute('description_html', $book->descriptionHtml());
return $book;
}
protected function rules(): array
{
return [
'create' => [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'default_template_id' => ['nullable', 'integer'],
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'default_template_id' => ['nullable', 'integer'],
],
];
}

View File

@@ -24,15 +24,11 @@ use Throwable;
class BookController extends Controller
{
protected BookRepo $bookRepo;
protected ShelfContext $shelfContext;
protected ReferenceFetcher $referenceFetcher;
public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo, ReferenceFetcher $referenceFetcher)
{
$this->bookRepo = $bookRepo;
$this->shelfContext = $entityContextManager;
$this->referenceFetcher = $referenceFetcher;
public function __construct(
protected ShelfContext $shelfContext,
protected BookRepo $bookRepo,
protected ReferenceFetcher $referenceFetcher
) {
}
/**
@@ -96,10 +92,11 @@ class BookController extends Controller
{
$this->checkPermission('book-create-all');
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'default_template_id' => ['nullable', 'integer'],
]);
$bookshelf = null;
@@ -141,7 +138,7 @@ class BookController extends Controller
'bookParentShelves' => $bookParentShelves,
'watchOptions' => new UserEntityWatchOptions(user(), $book),
'activity' => $activities->entityActivity($book, 20, 1),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book),
]);
}
@@ -170,10 +167,11 @@ class BookController extends Controller
$this->checkOwnablePermission('book-update', $book);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'default_template_id' => ['nullable', 'integer'],
]);
if ($request->has('image_reset')) {

View File

@@ -12,11 +12,9 @@ use Illuminate\Validation\ValidationException;
class BookshelfApiController extends ApiController
{
protected BookshelfRepo $bookshelfRepo;
public function __construct(BookshelfRepo $bookshelfRepo)
{
$this->bookshelfRepo = $bookshelfRepo;
public function __construct(
protected BookshelfRepo $bookshelfRepo
) {
}
/**
@@ -48,7 +46,7 @@ class BookshelfApiController extends ApiController
$bookIds = $request->get('books', []);
$shelf = $this->bookshelfRepo->create($requestData, $bookIds);
return response()->json($shelf);
return response()->json($this->forJsonDisplay($shelf));
}
/**
@@ -56,12 +54,14 @@ class BookshelfApiController extends ApiController
*/
public function read(string $id)
{
$shelf = Bookshelf::visible()->with([
'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
$shelf = Bookshelf::visible()->findOrFail($id);
$shelf = $this->forJsonDisplay($shelf);
$shelf->load([
'createdBy', 'updatedBy', 'ownedBy',
'books' => function (BelongsToMany $query) {
$query->scopes('visible')->get(['id', 'name', 'slug']);
},
])->findOrFail($id);
]);
return response()->json($shelf);
}
@@ -86,7 +86,7 @@ class BookshelfApiController extends ApiController
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
return response()->json($shelf);
return response()->json($this->forJsonDisplay($shelf));
}
/**
@@ -105,22 +105,36 @@ class BookshelfApiController extends ApiController
return response('', 204);
}
protected function forJsonDisplay(Bookshelf $shelf): Bookshelf
{
$shelf = clone $shelf;
$shelf->unsetRelations()->refresh();
$shelf->load(['tags', 'cover']);
$shelf->makeVisible('description_html')
->setAttribute('description_html', $shelf->descriptionHtml());
return $shelf;
}
protected function rules(): array
{
return [
'create' => [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'books' => ['array'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'books' => ['array'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'books' => ['array'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'books' => ['array'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
],
];
}

View File

@@ -18,15 +18,11 @@ use Illuminate\Validation\ValidationException;
class BookshelfController extends Controller
{
protected BookshelfRepo $shelfRepo;
protected ShelfContext $shelfContext;
protected ReferenceFetcher $referenceFetcher;
public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext, ReferenceFetcher $referenceFetcher)
{
$this->shelfRepo = $shelfRepo;
$this->shelfContext = $shelfContext;
$this->referenceFetcher = $referenceFetcher;
public function __construct(
protected BookshelfRepo $shelfRepo,
protected ShelfContext $shelfContext,
protected ReferenceFetcher $referenceFetcher
) {
}
/**
@@ -81,10 +77,10 @@ class BookshelfController extends Controller
{
$this->checkPermission('bookshelf-create-all');
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
]);
$bookIds = explode(',', $request->get('books', ''));
@@ -129,7 +125,7 @@ class BookshelfController extends Controller
'view' => $view,
'activity' => $activities->entityActivity($shelf, 20, 1),
'listOptions' => $listOptions,
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($shelf),
]);
}
@@ -164,10 +160,10 @@ class BookshelfController extends Controller
$shelf = $this->shelfRepo->getBySlug($slug);
$this->checkOwnablePermission('bookshelf-update', $shelf);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
]);
if ($request->has('image_reset')) {

View File

@@ -15,18 +15,20 @@ class ChapterApiController extends ApiController
{
protected $rules = [
'create' => [
'book_id' => ['required', 'integer'],
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'priority' => ['integer'],
'book_id' => ['required', 'integer'],
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'priority' => ['integer'],
],
'update' => [
'book_id' => ['integer'],
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'priority' => ['integer'],
'book_id' => ['integer'],
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'priority' => ['integer'],
],
];
@@ -61,7 +63,7 @@ class ChapterApiController extends ApiController
$chapter = $this->chapterRepo->create($requestData, $book);
return response()->json($chapter->load(['tags']));
return response()->json($this->forJsonDisplay($chapter));
}
/**
@@ -69,9 +71,15 @@ class ChapterApiController extends ApiController
*/
public function read(string $id)
{
$chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'ownedBy', 'pages' => function (HasMany $query) {
$query->scopes('visible')->get(['id', 'name', 'slug']);
}])->findOrFail($id);
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->forJsonDisplay($chapter);
$chapter->load([
'createdBy', 'updatedBy', 'ownedBy',
'pages' => function (HasMany $query) {
$query->scopes('visible')->get(['id', 'name', 'slug']);
}
]);
return response()->json($chapter);
}
@@ -93,7 +101,7 @@ class ChapterApiController extends ApiController
try {
$this->chapterRepo->move($chapter, "book:{$requestData['book_id']}");
} catch (Exception $exception) {
if ($exception instanceof PermissionsException) {
if ($exception instanceof PermissionsException) {
$this->showPermissionError();
}
@@ -103,7 +111,7 @@ class ChapterApiController extends ApiController
$updatedChapter = $this->chapterRepo->update($chapter, $requestData);
return response()->json($updatedChapter->load(['tags']));
return response()->json($this->forJsonDisplay($updatedChapter));
}
/**
@@ -119,4 +127,16 @@ class ChapterApiController extends ApiController
return response('', 204);
}
protected function forJsonDisplay(Chapter $chapter): Chapter
{
$chapter = clone $chapter;
$chapter->unsetRelations()->refresh();
$chapter->load(['tags']);
$chapter->makeVisible('description_html')
->setAttribute('description_html', $chapter->descriptionHtml());
return $chapter;
}
}

View File

@@ -12,6 +12,7 @@ use BookStack\Entities\Tools\HierarchyTransformer;
use BookStack\Entities\Tools\NextPreviousContentLocator;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\Controller;
use BookStack\References\ReferenceFetcher;
@@ -21,13 +22,10 @@ use Throwable;
class ChapterController extends Controller
{
protected ChapterRepo $chapterRepo;
protected ReferenceFetcher $referenceFetcher;
public function __construct(ChapterRepo $chapterRepo, ReferenceFetcher $referenceFetcher)
{
$this->chapterRepo = $chapterRepo;
$this->referenceFetcher = $referenceFetcher;
public function __construct(
protected ChapterRepo $chapterRepo,
protected ReferenceFetcher $referenceFetcher
) {
}
/**
@@ -50,14 +48,16 @@ class ChapterController extends Controller
*/
public function store(Request $request, string $bookSlug)
{
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
]);
$book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
$this->checkOwnablePermission('chapter-create', $book);
$chapter = $this->chapterRepo->create($request->all(), $book);
$chapter = $this->chapterRepo->create($validated, $book);
return redirect($chapter->getUrl());
}
@@ -86,7 +86,7 @@ class ChapterController extends Controller
'pages' => $pages,
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($chapter),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($chapter),
]);
}
@@ -110,10 +110,16 @@ class ChapterController extends Controller
*/
public function update(Request $request, string $bookSlug, string $chapterSlug)
{
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
]);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->chapterRepo->update($chapter, $request->all());
$this->chapterRepo->update($chapter, $validated);
return redirect($chapter->getUrl());
}
@@ -170,7 +176,7 @@ class ChapterController extends Controller
/**
* Perform the move action for a chapter.
*
* @throws NotFoundException
* @throws NotFoundException|NotifyException
*/
public function move(Request $request, string $bookSlug, string $chapterSlug)
{
@@ -184,13 +190,13 @@ class ChapterController extends Controller
}
try {
$newBook = $this->chapterRepo->move($chapter, $entitySelection);
$this->chapterRepo->move($chapter, $entitySelection);
} catch (PermissionsException $exception) {
$this->showPermissionError();
} catch (MoveOperationException $exception) {
$this->showErrorNotification(trans('errors.selected_book_not_found'));
return redirect()->back();
return redirect($chapter->getUrl('/move'));
}
return redirect($chapter->getUrl());
@@ -231,7 +237,7 @@ class ChapterController extends Controller
if (is_null($newParentBook)) {
$this->showErrorNotification(trans('errors.selected_book_not_found'));
return redirect()->back();
return redirect($chapter->getUrl('/copy'));
}
$this->checkOwnablePermission('chapter-create', $newParentBook);

View File

@@ -5,6 +5,7 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\BookContents;
@@ -71,7 +72,6 @@ class PageController extends Controller
$page = $this->pageRepo->getNewDraftPage($parent);
$this->pageRepo->publishDraft($page, [
'name' => $request->get('name'),
'html' => '',
]);
return redirect($page->getUrl('/edit'));
@@ -155,7 +155,7 @@ class PageController extends Controller
'watchOptions' => new UserEntityWatchOptions(user(), $page),
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($page),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($page),
]);
}
@@ -259,11 +259,13 @@ class PageController extends Controller
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()]));
$usedAsTemplate = Book::query()->where('default_template_id', '=', $page->id)->count() > 0;
return view('pages.delete', [
'book' => $page->book,
'page' => $page,
'current' => $page,
'usedAsTemplate' => $usedAsTemplate,
]);
}
@@ -277,11 +279,13 @@ class PageController extends Controller
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-update', $page);
$this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()]));
$usedAsTemplate = Book::query()->where('default_template_id', '=', $page->id)->count() > 0;
return view('pages.delete', [
'book' => $page->book,
'page' => $page,
'current' => $page,
'usedAsTemplate' => $usedAsTemplate,
]);
}
@@ -391,7 +395,7 @@ class PageController extends Controller
} catch (Exception $exception) {
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect()->back();
return redirect($page->getUrl('/move'));
}
return redirect($page->getUrl());
@@ -431,7 +435,7 @@ class PageController extends Controller
if (is_null($newParent)) {
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect()->back();
return redirect($page->getUrl('/copy'));
}
$this->checkOwnablePermission('page-create', $newParent);

View File

@@ -15,20 +15,23 @@ use Illuminate\Support\Collection;
*
* @property string $description
* @property int $image_id
* @property ?int $default_template_id
* @property Image|null $cover
* @property \Illuminate\Database\Eloquent\Collection $chapters
* @property \Illuminate\Database\Eloquent\Collection $pages
* @property \Illuminate\Database\Eloquent\Collection $directPages
* @property \Illuminate\Database\Eloquent\Collection $shelves
* @property ?Page $defaultTemplate
*/
class Book extends Entity implements HasCoverImage
{
use HasFactory;
use HasHtmlDescription;
public $searchFactor = 1.2;
public float $searchFactor = 1.2;
protected $fillable = ['name', 'description'];
protected $hidden = ['pivot', 'image_id', 'deleted_at'];
protected $fillable = ['name'];
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html'];
/**
* Get the url for this book.
@@ -71,6 +74,14 @@ class Book extends Entity implements HasCoverImage
return 'cover_book';
}
/**
* Get the Page that is used as default template for newly created pages within this Book.
*/
public function defaultTemplate(): BelongsTo
{
return $this->belongsTo(Page::class, 'default_template_id');
}
/**
* Get all pages within this book.
*/

View File

@@ -65,7 +65,7 @@ abstract class BookChild extends Entity
$this->refresh();
if ($oldUrl !== $this->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityPageReferences($this, $oldUrl);
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
}
// Update all child pages if a chapter

View File

@@ -11,14 +11,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Bookshelf extends Entity implements HasCoverImage
{
use HasFactory;
use HasHtmlDescription;
protected $table = 'bookshelves';
public $searchFactor = 1.2;
public float $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'image_id'];
protected $hidden = ['image_id', 'deleted_at'];
protected $hidden = ['image_id', 'deleted_at', 'description_html'];
/**
* Get the books in this shelf.

View File

@@ -15,11 +15,12 @@ use Illuminate\Support\Collection;
class Chapter extends BookChild
{
use HasFactory;
use HasHtmlDescription;
public $searchFactor = 1.2;
public float $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'priority'];
protected $hidden = ['pivot', 'deleted_at'];
protected $hidden = ['pivot', 'deleted_at', 'description_html'];
/**
* Get the pages that this chapter contains.

View File

@@ -57,12 +57,17 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/**
* @var string - Name of property where the main text content is found
*/
public $textField = 'description';
public string $textField = 'description';
/**
* @var string - Name of the property where the main HTML content is found
*/
public string $htmlField = 'description_html';
/**
* @var float - Multiplier for search indexing.
*/
public $searchFactor = 1.0;
public float $searchFactor = 1.0;
/**
* Get the entities that are visible to the current user.

View File

@@ -0,0 +1,21 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Util\HtmlContentFilter;
/**
* @property string $description
* @property string $description_html
*/
trait HasHtmlDescription
{
/**
* Get the HTML description for this book.
*/
public function descriptionHtml(): string
{
$html = $this->description_html ?: '<p>' . nl2br(e($this->description)) . '</p>';
return HtmlContentFilter::removeScriptsFromHtmlString($html);
}
}

View File

@@ -37,7 +37,8 @@ class Page extends BookChild
protected $fillable = ['name', 'priority'];
public $textField = 'text';
public string $textField = 'text';
public string $htmlField = 'html';
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];

View File

@@ -5,22 +5,22 @@ namespace BookStack\Entities\Repos;
use BookStack\Activity\TagRepo;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
use BookStack\Entities\Models\HasHtmlDescription;
use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater;
use BookStack\Uploads\ImageRepo;
use BookStack\Util\HtmlDescriptionFilter;
use Illuminate\Http\UploadedFile;
class BaseRepo
{
protected TagRepo $tagRepo;
protected ImageRepo $imageRepo;
protected ReferenceUpdater $referenceUpdater;
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo, ReferenceUpdater $referenceUpdater)
{
$this->tagRepo = $tagRepo;
$this->imageRepo = $imageRepo;
$this->referenceUpdater = $referenceUpdater;
public function __construct(
protected TagRepo $tagRepo,
protected ImageRepo $imageRepo,
protected ReferenceUpdater $referenceUpdater,
protected ReferenceStore $referenceStore,
) {
}
/**
@@ -29,6 +29,7 @@ class BaseRepo
public function create(Entity $entity, array $input)
{
$entity->fill($input);
$this->updateDescription($entity, $input);
$entity->forceFill([
'created_by' => user()->id,
'updated_by' => user()->id,
@@ -44,6 +45,7 @@ class BaseRepo
$entity->refresh();
$entity->rebuildPermissions();
$entity->indexForSearch();
$this->referenceStore->updateForEntity($entity);
}
/**
@@ -54,6 +56,7 @@ class BaseRepo
$oldUrl = $entity->getUrl();
$entity->fill($input);
$this->updateDescription($entity, $input);
$entity->updated_by = user()->id;
if ($entity->isDirty('name') || empty($entity->slug)) {
@@ -69,9 +72,10 @@ class BaseRepo
$entity->rebuildPermissions();
$entity->indexForSearch();
$this->referenceStore->updateForEntity($entity);
if ($oldUrl !== $entity->getUrl()) {
$this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);
$this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
}
}
@@ -99,4 +103,21 @@ class BaseRepo
$entity->save();
}
}
protected function updateDescription(Entity $entity, array $input): void
{
if (!in_array(HasHtmlDescription::class, class_uses($entity))) {
return;
}
/** @var HasHtmlDescription $entity */
if (isset($input['description_html'])) {
$entity->description_html = HtmlDescriptionFilter::filterFromString($input['description_html']);
$entity->description = html_entity_decode(strip_tags($input['description_html']));
} else if (isset($input['description'])) {
$entity->description = $input['description'];
$entity->description_html = '';
$entity->description_html = $entity->descriptionHtml();
}
}
}

View File

@@ -5,6 +5,7 @@ namespace BookStack\Entities\Repos;
use BookStack\Activity\ActivityType;
use BookStack\Activity\TagRepo;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
@@ -17,18 +18,11 @@ use Illuminate\Support\Collection;
class BookRepo
{
protected $baseRepo;
protected $tagRepo;
protected $imageRepo;
/**
* BookRepo constructor.
*/
public function __construct(BaseRepo $baseRepo, TagRepo $tagRepo, ImageRepo $imageRepo)
{
$this->baseRepo = $baseRepo;
$this->tagRepo = $tagRepo;
$this->imageRepo = $imageRepo;
public function __construct(
protected BaseRepo $baseRepo,
protected TagRepo $tagRepo,
protected ImageRepo $imageRepo
) {
}
/**
@@ -92,6 +86,7 @@ class BookRepo
$book = new Book();
$this->baseRepo->create($book, $input);
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
$this->updateBookDefaultTemplate($book, intval($input['default_template_id'] ?? null));
Activity::add(ActivityType::BOOK_CREATE, $book);
return $book;
@@ -104,6 +99,10 @@ class BookRepo
{
$this->baseRepo->update($book, $input);
if (array_key_exists('default_template_id', $input)) {
$this->updateBookDefaultTemplate($book, intval($input['default_template_id']));
}
if (array_key_exists('image', $input)) {
$this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
}
@@ -113,6 +112,33 @@ class BookRepo
return $book;
}
/**
* Update the default page template used for this book.
* Checks that, if changing, the provided value is a valid template and the user
* has visibility of the provided page template id.
*/
protected function updateBookDefaultTemplate(Book $book, int $templateId): void
{
$changing = $templateId !== intval($book->default_template_id);
if (!$changing) {
return;
}
if ($templateId === 0) {
$book->default_template_id = null;
$book->save();
return;
}
$templateExists = Page::query()->visible()
->where('template', '=', true)
->where('id', '=', $templateId)
->exists();
$book->default_template_id = $templateExists ? $templateId : null;
$book->save();
}
/**
* Update the given book's cover image, or clear it.
*

View File

@@ -136,6 +136,14 @@ class PageRepo
$page->book_id = $parent->id;
}
$defaultTemplate = $page->book->defaultTemplate;
if ($defaultTemplate && userCan('view', $defaultTemplate)) {
$page->forceFill([
'html' => $defaultTemplate->html,
'markdown' => $defaultTemplate->markdown,
]);
}
$page->save();
$page->refresh()->rebuildPermissions();
@@ -154,7 +162,6 @@ class PageRepo
$this->baseRepo->update($draft, $input);
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
$this->referenceStore->updateForPage($draft);
$draft->refresh();
Activity::add(ActivityType::PAGE_CREATE, $draft);
@@ -174,7 +181,6 @@ class PageRepo
$this->updateTemplateStatusAndContentFromInput($page, $input);
$this->baseRepo->update($page, $input);
$this->referenceStore->updateForPage($page);
// Update with new details
$page->revision_count++;
@@ -211,13 +217,13 @@ class PageRepo
$inputEmpty = empty($input['markdown']) && empty($input['html']);
if ($haveInput && $inputEmpty) {
$pageContent->setNewHTML('');
$pageContent->setNewHTML('', user());
} elseif (!empty($input['markdown']) && is_string($input['markdown'])) {
$newEditor = 'markdown';
$pageContent->setNewMarkdown($input['markdown']);
$pageContent->setNewMarkdown($input['markdown'], user());
} elseif (isset($input['html'])) {
$newEditor = 'wysiwyg';
$pageContent->setNewHTML($input['html']);
$pageContent->setNewHTML($input['html'], user());
}
if ($newEditor !== $currentEditor && userCan('editor-change')) {
@@ -284,22 +290,22 @@ class PageRepo
$content = new PageContent($page);
if (!empty($revision->markdown)) {
$content->setNewMarkdown($revision->markdown);
$content->setNewMarkdown($revision->markdown, user());
} else {
$content->setNewHTML($revision->html);
$content->setNewHTML($revision->html, user());
}
$page->updated_by = user()->id;
$page->refreshSlug();
$page->save();
$page->indexForSearch();
$this->referenceStore->updateForPage($page);
$this->referenceStore->updateForEntity($page);
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
$this->revisionRepo->storeNewForPage($page, $summary);
if ($oldUrl !== $page->getUrl()) {
$this->referenceUpdater->updateEntityPageReferences($page, $oldUrl);
$this->referenceUpdater->updateEntityReferences($page, $oldUrl);
}
Activity::add(ActivityType::PAGE_RESTORE, $page);

View File

@@ -8,9 +8,8 @@ use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Uploads\ImageService;
use BookStack\Util\CspService;
use DOMDocument;
use BookStack\Util\HtmlDocument;
use DOMElement;
use DOMXPath;
use Exception;
use Throwable;
@@ -151,45 +150,36 @@ class ExportFormatter
protected function htmlToPdf(string $html): string
{
$html = $this->containHtml($html);
$html = $this->replaceIframesWithLinks($html);
$html = $this->openDetailElements($html);
$doc = new HtmlDocument();
$doc->loadCompleteHtml($html);
return $this->pdfGenerator->fromHtml($html);
$this->replaceIframesWithLinks($doc);
$this->openDetailElements($doc);
$cleanedHtml = $doc->getHtml();
return $this->pdfGenerator->fromHtml($cleanedHtml);
}
/**
* Within the given HTML content, Open any detail blocks.
*/
protected function openDetailElements(string $html): string
protected function openDetailElements(HtmlDocument $doc): void
{
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$details = $xPath->query('//details');
$details = $doc->queryXPath('//details');
/** @var DOMElement $detail */
foreach ($details as $detail) {
$detail->setAttribute('open', 'open');
}
return $doc->saveHTML();
}
/**
* Within the given HTML content, replace any iframe elements
* Within the given HTML document, replace any iframe elements
* with anchor links within paragraph blocks.
*/
protected function replaceIframesWithLinks(string $html): string
protected function replaceIframesWithLinks(HtmlDocument $doc): void
{
libxml_use_internal_errors(true);
$iframes = $doc->queryXPath('//iframe');
$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');
@@ -203,8 +193,6 @@ class ExportFormatter
$paragraph->appendChild($anchor);
$iframe->parentNode->replaceChild($paragraph, $iframe);
}
return $doc->saveHTML();
}
/**

View File

@@ -0,0 +1,103 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\App\Model;
use BookStack\Entities\EntityProvider;
use Illuminate\Database\Eloquent\Relations\Relation;
class MixedEntityListLoader
{
protected array $listAttributes = [
'page' => ['id', 'name', 'slug', 'book_id', 'chapter_id', 'text', 'draft'],
'chapter' => ['id', 'name', 'slug', 'book_id', 'description'],
'book' => ['id', 'name', 'slug', 'description'],
'bookshelf' => ['id', 'name', 'slug', 'description'],
];
public function __construct(
protected EntityProvider $entityProvider
) {
}
/**
* Efficiently load in entities for listing onto the given list
* where entities are set as a relation via the given name.
* This will look for a model id and type via 'name_id' and 'name_type'.
* @param Model[] $relations
*/
public function loadIntoRelations(array $relations, string $relationName): void
{
$idsByType = [];
foreach ($relations as $relation) {
$type = $relation->getAttribute($relationName . '_type');
$id = $relation->getAttribute($relationName . '_id');
if (!isset($idsByType[$type])) {
$idsByType[$type] = [];
}
$idsByType[$type][] = $id;
}
$modelMap = $this->idsByTypeToModelMap($idsByType);
foreach ($relations as $relation) {
$type = $relation->getAttribute($relationName . '_type');
$id = $relation->getAttribute($relationName . '_id');
$related = $modelMap[$type][strval($id)] ?? null;
if ($related) {
$relation->setRelation($relationName, $related);
}
}
}
/**
* @param array<string, int[]> $idsByType
* @return array<string, array<int, Model>>
*/
protected function idsByTypeToModelMap(array $idsByType): array
{
$modelMap = [];
foreach ($idsByType as $type => $ids) {
if (!isset($this->listAttributes[$type])) {
continue;
}
$instance = $this->entityProvider->get($type);
$models = $instance->newQuery()
->select($this->listAttributes[$type])
->scopes('visible')
->whereIn('id', $ids)
->with($this->getRelationsToEagerLoad($type))
->get();
if (count($models) > 0) {
$modelMap[$type] = [];
}
foreach ($models as $model) {
$modelMap[$type][strval($model->id)] = $model;
}
}
return $modelMap;
}
protected function getRelationsToEagerLoad(string $type): array
{
$toLoad = [];
$loadVisible = fn (Relation $query) => $query->scopes('visible');
if ($type === 'chapter' || $type === 'page') {
$toLoad['book'] = $loadVisible;
}
if ($type === 'page') {
$toLoad['chapter'] = $loadVisible;
}
return $toLoad;
}
}

View File

@@ -9,12 +9,14 @@ use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService;
use BookStack\Users\Models\User;
use BookStack\Util\HtmlContentFilter;
use DOMDocument;
use BookStack\Util\HtmlDocument;
use BookStack\Util\WebSafeMimeSniffer;
use Closure;
use DOMElement;
use DOMNode;
use DOMNodeList;
use DOMXPath;
use Illuminate\Support\Str;
class PageContent
@@ -27,9 +29,9 @@ class PageContent
/**
* Update the content of the page with new provided HTML.
*/
public function setNewHTML(string $html): void
public function setNewHTML(string $html, User $updater): void
{
$html = $this->extractBase64ImagesFromHtml($html);
$html = $this->extractBase64ImagesFromHtml($html, $updater);
$this->page->html = $this->formatHtml($html);
$this->page->text = $this->toPlainText();
$this->page->markdown = '';
@@ -38,9 +40,9 @@ class PageContent
/**
* Update the content of the page with new provided Markdown content.
*/
public function setNewMarkdown(string $markdown): void
public function setNewMarkdown(string $markdown, User $updater): void
{
$markdown = $this->extractBase64ImagesFromMarkdown($markdown);
$markdown = $this->extractBase64ImagesFromMarkdown($markdown, $updater);
$this->page->markdown = $markdown;
$html = (new MarkdownToHtml($markdown))->convert();
$this->page->html = $this->formatHtml($html);
@@ -50,33 +52,24 @@ class PageContent
/**
* Convert all base64 image data to saved images.
*/
protected function extractBase64ImagesFromHtml(string $htmlText): string
protected function extractBase64ImagesFromHtml(string $htmlText, User $updater): string
{
if (empty($htmlText) || !str_contains($htmlText, 'data:image')) {
return $htmlText;
}
$doc = $this->loadDocumentFromHtml($htmlText);
$container = $doc->documentElement;
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
$xPath = new DOMXPath($doc);
$doc = new HtmlDocument($htmlText);
// Get all img elements with image data blobs
$imageNodes = $xPath->query('//img[contains(@src, \'data:image\')]');
$imageNodes = $doc->queryXPath('//img[contains(@src, \'data:image\')]');
/** @var DOMElement $imageNode */
foreach ($imageNodes as $imageNode) {
$imageSrc = $imageNode->getAttribute('src');
$newUrl = $this->base64ImageUriToUploadedImageUrl($imageSrc);
$newUrl = $this->base64ImageUriToUploadedImageUrl($imageSrc, $updater);
$imageNode->setAttribute('src', $newUrl);
}
// Generate inner html as a string
$html = '';
foreach ($childNodes as $childNode) {
$html .= $doc->saveHTML($childNode);
}
return $html;
return $doc->getBodyInnerHtml();
}
/**
@@ -86,7 +79,7 @@ class PageContent
* Attempting to capture the whole data uri using regex can cause PHP
* PCRE limits to be hit with larger, multi-MB, files.
*/
protected function extractBase64ImagesFromMarkdown(string $markdown): string
protected function extractBase64ImagesFromMarkdown(string $markdown, User $updater): string
{
$matches = [];
$contentLength = strlen($markdown);
@@ -104,7 +97,7 @@ class PageContent
$dataUri .= $char;
}
$newUrl = $this->base64ImageUriToUploadedImageUrl($dataUri);
$newUrl = $this->base64ImageUriToUploadedImageUrl($dataUri, $updater);
$replacements[] = [$dataUri, $newUrl];
}
@@ -119,16 +112,28 @@ class PageContent
* Parse the given base64 image URI and return the URL to the created image instance.
* Returns an empty string if the parsed URI is invalid or causes an error upon upload.
*/
protected function base64ImageUriToUploadedImageUrl(string $uri): string
protected function base64ImageUriToUploadedImageUrl(string $uri, User $updater): string
{
$imageRepo = app()->make(ImageRepo::class);
$imageInfo = $this->parseBase64ImageUri($uri);
// Validate user has permission to create images
if (!$updater->can('image-create-all')) {
return '';
}
// Validate extension and content
if (empty($imageInfo['data']) || !ImageService::isExtensionSupported($imageInfo['extension'])) {
return '';
}
// Validate content looks like an image via sniffing mime type
$mimeSniffer = new WebSafeMimeSniffer();
$mime = $mimeSniffer->sniff($imageInfo['data']);
if (!str_starts_with($mime, 'image/')) {
return '';
}
// Validate that the content is not over our upload limit
$uploadLimitBytes = (config('app.upload_limit') * 1000000);
if (strlen($imageInfo['data']) > $uploadLimitBytes) {
@@ -172,27 +177,18 @@ class PageContent
return $htmlText;
}
$doc = $this->loadDocumentFromHtml($htmlText);
$container = $doc->documentElement;
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
$xPath = new DOMXPath($doc);
$doc = new HtmlDocument($htmlText);
// Map to hold used ID references
$idMap = [];
// Map to hold changing ID references
$changeMap = [];
$this->updateIdsRecursively($body, 0, $idMap, $changeMap);
$this->updateLinks($xPath, $changeMap);
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
$this->updateLinks($doc, $changeMap);
// Generate inner html as a string
$html = '';
foreach ($childNodes as $childNode) {
$html .= $doc->saveHTML($childNode);
}
// Perform required string-level tweaks
// Generate inner html as a string & perform required string-level tweaks
$html = $doc->getBodyInnerHtml();
$html = str_replace(' ', '&nbsp;', $html);
return $html;
@@ -225,13 +221,13 @@ class PageContent
* Update the all links in the given xpath to apply requires changes within the
* given $changeMap array.
*/
protected function updateLinks(DOMXPath $xpath, array $changeMap): void
protected function updateLinks(HtmlDocument $doc, array $changeMap): void
{
if (empty($changeMap)) {
return;
}
$links = $xpath->query('//body//*//*[@href]');
$links = $doc->queryXPath('//body//*//*[@href]');
/** @var DOMElement $domElem */
foreach ($links as $domElem) {
$href = ltrim($domElem->getAttribute('href'), '#');
@@ -295,21 +291,65 @@ class PageContent
*/
public function render(bool $blankIncludes = false): string
{
$content = $this->page->html ?? '';
$html = $this->page->html ?? '';
if (empty($html)) {
return $html;
}
$doc = new HtmlDocument($html);
$contentProvider = $this->getContentProviderClosure($blankIncludes);
$parser = new PageIncludeParser($doc, $contentProvider);
$nodesAdded = 1;
for ($includeDepth = 0; $includeDepth < 3 && $nodesAdded !== 0; $includeDepth++) {
$nodesAdded = $parser->parse();
}
if ($includeDepth > 1) {
$idMap = [];
$changeMap = [];
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
}
if (!config('app.allow_content_scripts')) {
$content = HtmlContentFilter::removeScripts($content);
HtmlContentFilter::removeScriptsFromDocument($doc);
}
if ($blankIncludes) {
$content = $this->blankPageIncludes($content);
} else {
for ($includeDepth = 0; $includeDepth < 3; $includeDepth++) {
$content = $this->parsePageIncludes($content);
return $doc->getBodyInnerHtml();
}
/**
* Get the closure used to fetch content for page includes.
*/
protected function getContentProviderClosure(bool $blankIncludes): Closure
{
$contextPage = $this->page;
return function (PageIncludeTag $tag) use ($blankIncludes, $contextPage): PageIncludeContent {
if ($blankIncludes) {
return PageIncludeContent::fromHtmlAndTag('', $tag);
}
}
return $content;
$matchedPage = Page::visible()->find($tag->getPageId());
$content = PageIncludeContent::fromHtmlAndTag($matchedPage->html ?? '', $tag);
if (Theme::hasListeners(ThemeEvents::PAGE_INCLUDE_PARSE)) {
$themeReplacement = Theme::dispatch(
ThemeEvents::PAGE_INCLUDE_PARSE,
$tag->tagContent,
$content->toHtml(),
clone $contextPage,
$matchedPage ? (clone $matchedPage) : null,
);
if ($themeReplacement !== null) {
$content = PageIncludeContent::fromInlineHtml(strval($themeReplacement));
}
}
return $content;
};
}
/**
@@ -321,11 +361,10 @@ class PageContent
return [];
}
$doc = $this->loadDocumentFromHtml($htmlContent);
$xPath = new DOMXPath($doc);
$headers = $xPath->query('//h1|//h2|//h3|//h4|//h5|//h6');
$doc = new HtmlDocument($htmlContent);
$headers = $doc->queryXPath('//h1|//h2|//h3|//h4|//h5|//h6');
return $headers ? $this->headerNodesToLevelList($headers) : [];
return $headers->count() === 0 ? [] : $this->headerNodesToLevelList($headers);
}
/**
@@ -358,102 +397,4 @@ class PageContent
return $tree->toArray();
}
/**
* Remove any page include tags within the given HTML.
*/
protected function blankPageIncludes(string $html): string
{
return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
}
/**
* Parse any include tags "{{@<page_id>#section}}" to be part of the page.
*/
protected function parsePageIncludes(string $html): string
{
$matches = [];
preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
foreach ($matches[1] as $index => $includeId) {
$fullMatch = $matches[0][$index];
$splitInclude = explode('#', $includeId, 2);
// Get page id from reference
$pageId = intval($splitInclude[0]);
if (is_nan($pageId)) {
continue;
}
// Find page to use, and default replacement to empty string for non-matches.
/** @var ?Page $matchedPage */
$matchedPage = Page::visible()->find($pageId);
$replacement = '';
if ($matchedPage && count($splitInclude) === 1) {
// If we only have page id, just insert all page html and continue.
$replacement = $matchedPage->html;
} elseif ($matchedPage && count($splitInclude) > 1) {
// Otherwise, if our include tag defines a section, load that specific content
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
$replacement = trim($innerContent);
}
$themeReplacement = Theme::dispatch(
ThemeEvents::PAGE_INCLUDE_PARSE,
$includeId,
$replacement,
clone $this->page,
$matchedPage ? (clone $matchedPage) : null,
);
// Perform the content replacement
$html = str_replace($fullMatch, $themeReplacement ?? $replacement, $html);
}
return $html;
}
/**
* Fetch the content from a specific section of the given page.
*/
protected function fetchSectionOfPage(Page $page, string $sectionId): string
{
$topLevelTags = ['table', 'ul', 'ol', 'pre'];
$doc = $this->loadDocumentFromHtml($page->html);
// Search included content for the id given and blank out if not exists.
$matchingElem = $doc->getElementById($sectionId);
if ($matchingElem === null) {
return '';
}
// Otherwise replace the content with the found content
// Checks if the top-level wrapper should be included by matching on tag types
$innerContent = '';
$isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
if ($isTopLevel) {
$innerContent .= $doc->saveHTML($matchingElem);
} else {
foreach ($matchingElem->childNodes as $childNode) {
$innerContent .= $doc->saveHTML($childNode);
}
}
libxml_clear_errors();
return $innerContent;
}
/**
* Create and load a DOMDocument from the given html content.
*/
protected function loadDocumentFromHtml(string $html): DOMDocument
{
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
$doc->loadHTML($html);
return $doc;
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Util\HtmlDocument;
use DOMNode;
class PageIncludeContent
{
protected static array $topLevelTags = ['table', 'ul', 'ol', 'pre'];
/**
* @param DOMNode[] $contents
* @param bool $isInline
*/
public function __construct(
protected array $contents,
protected bool $isInline,
) {
}
public static function fromHtmlAndTag(string $html, PageIncludeTag $tag): self
{
if (empty($html)) {
return new self([], true);
}
$doc = new HtmlDocument($html);
$sectionId = $tag->getSectionId();
if (!$sectionId) {
$contents = [...$doc->getBodyChildren()];
return new self($contents, false);
}
$section = $doc->getElementById($sectionId);
if (!$section) {
return new self([], true);
}
$isTopLevel = in_array(strtolower($section->nodeName), static::$topLevelTags);
$contents = $isTopLevel ? [$section] : [...$section->childNodes];
return new self($contents, !$isTopLevel);
}
public static function fromInlineHtml(string $html): self
{
if (empty($html)) {
return new self([], true);
}
$doc = new HtmlDocument($html);
return new self([...$doc->getBodyChildren()], true);
}
public function isInline(): bool
{
return $this->isInline;
}
public function isEmpty(): bool
{
return empty($this->contents);
}
/**
* @return DOMNode[]
*/
public function toDomNodes(): array
{
return $this->contents;
}
public function toHtml(): string
{
$html = '';
foreach ($this->contents as $content) {
$html .= $content->ownerDocument->saveHTML($content);
}
return $html;
}
}

View File

@@ -0,0 +1,220 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Util\HtmlDocument;
use Closure;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMText;
class PageIncludeParser
{
protected static string $includeTagRegex = "/{{@\s?([0-9].*?)}}/";
/**
* Elements to clean up and remove if left empty after a parsing operation.
* @var DOMElement[]
*/
protected array $toCleanup = [];
/**
* @param Closure(PageIncludeTag $tag): PageContent $pageContentForId
*/
public function __construct(
protected HtmlDocument $doc,
protected Closure $pageContentForId,
) {
}
/**
* Parse out the include tags.
* Returns the count of new content DOM nodes added to the document.
*/
public function parse(): int
{
$nodesAdded = 0;
$tags = $this->locateAndIsolateIncludeTags();
foreach ($tags as $tag) {
/** @var PageIncludeContent $content */
$content = $this->pageContentForId->call($this, $tag);
if (!$content->isInline()) {
$parentP = $this->getParentParagraph($tag->domNode);
$isWithinParentP = $parentP === $tag->domNode->parentNode;
if ($parentP && $isWithinParentP) {
$this->splitNodeAtChildNode($tag->domNode->parentNode, $tag->domNode);
} else if ($parentP) {
$this->moveTagNodeToBesideParent($tag, $parentP);
}
}
$replacementNodes = $content->toDomNodes();
$nodesAdded += count($replacementNodes);
$this->replaceNodeWithNodes($tag->domNode, $replacementNodes);
}
$this->cleanup();
return $nodesAdded;
}
/**
* Locate include tags within the given document, isolating them to their
* own nodes in the DOM for future targeted manipulation.
* @return PageIncludeTag[]
*/
protected function locateAndIsolateIncludeTags(): array
{
$includeHosts = $this->doc->queryXPath("//*[text()[contains(., '{{@')]]");
$includeTags = [];
/** @var DOMNode $node */
foreach ($includeHosts as $node) {
/** @var DOMNode $childNode */
foreach ($node->childNodes as $childNode) {
if ($childNode->nodeName === '#text') {
array_push($includeTags, ...$this->splitTextNodesAtTags($childNode));
}
}
}
return $includeTags;
}
/**
* Takes a text DOMNode and splits its text content at include tags
* into multiple text nodes within the original parent.
* Returns found PageIncludeTag references.
* @return PageIncludeTag[]
*/
protected function splitTextNodesAtTags(DOMNode $textNode): array
{
$includeTags = [];
$text = $textNode->textContent;
preg_match_all(static::$includeTagRegex, $text, $matches, PREG_OFFSET_CAPTURE);
$currentOffset = 0;
foreach ($matches[0] as $index => $fullTagMatch) {
$tagOuterContent = $fullTagMatch[0];
$tagInnerContent = $matches[1][$index][0];
$tagStartOffset = $fullTagMatch[1];
if ($currentOffset < $tagStartOffset) {
$previousText = substr($text, $currentOffset, $tagStartOffset - $currentOffset);
$textNode->parentNode->insertBefore(new DOMText($previousText), $textNode);
}
$node = $textNode->parentNode->insertBefore(new DOMText($tagOuterContent), $textNode);
$includeTags[] = new PageIncludeTag($tagInnerContent, $node);
$currentOffset = $tagStartOffset + strlen($tagOuterContent);
}
if ($currentOffset > 0) {
$textNode->textContent = substr($text, $currentOffset);
}
return $includeTags;
}
/**
* Replace the given node with all those in $replacements
* @param DOMNode[] $replacements
*/
protected function replaceNodeWithNodes(DOMNode $toReplace, array $replacements): void
{
/** @var DOMDocument $targetDoc */
$targetDoc = $toReplace->ownerDocument;
foreach ($replacements as $replacement) {
if ($replacement->ownerDocument !== $targetDoc) {
$replacement = $targetDoc->importNode($replacement, true);
}
$toReplace->parentNode->insertBefore($replacement, $toReplace);
}
$toReplace->parentNode->removeChild($toReplace);
}
/**
* Move a tag node to become a sibling of the given parent.
* Will attempt to guess a position based upon the tag content within the parent.
*/
protected function moveTagNodeToBesideParent(PageIncludeTag $tag, DOMNode $parent): void
{
$parentText = $parent->textContent;
$tagPos = strpos($parentText, $tag->tagContent);
$before = $tagPos < (strlen($parentText) / 2);
$this->toCleanup[] = $tag->domNode->parentNode;
if ($before) {
$parent->parentNode->insertBefore($tag->domNode, $parent);
} else {
$parent->parentNode->insertBefore($tag->domNode, $parent->nextSibling);
}
}
/**
* Splits the given $parentNode at the location of the $domNode within it.
* Attempts replicate the original $parentNode, moving some of their parent
* children in where needed, before adding the $domNode between.
*/
protected function splitNodeAtChildNode(DOMElement $parentNode, DOMNode $domNode): void
{
$children = [...$parentNode->childNodes];
$splitPos = array_search($domNode, $children, true);
if ($splitPos === false) {
$splitPos = count($children) - 1;
}
$parentClone = $parentNode->cloneNode();
$parentNode->parentNode->insertBefore($parentClone, $parentNode);
$parentClone->removeAttribute('id');
for ($i = 0; $i < $splitPos; $i++) {
/** @var DOMNode $child */
$child = $children[$i];
$parentClone->appendChild($child);
}
$parentNode->parentNode->insertBefore($domNode, $parentNode);
$this->toCleanup[] = $parentNode;
$this->toCleanup[] = $parentClone;
}
/**
* Get the parent paragraph of the given node, if existing.
*/
protected function getParentParagraph(DOMNode $parent): ?DOMNode
{
do {
if (strtolower($parent->nodeName) === 'p') {
return $parent;
}
$parent = $parent->parentNode;
} while ($parent !== null);
return null;
}
/**
* Cleanup after a parse operation.
* Removes stranded elements we may have left during the parse.
*/
protected function cleanup(): void
{
foreach ($this->toCleanup as $element) {
$element->normalize();
while ($element->parentNode && !$element->hasChildNodes()) {
$parent = $element->parentNode;
$parent->removeChild($element);
$element = $parent;
}
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace BookStack\Entities\Tools;
use DOMNode;
class PageIncludeTag
{
public function __construct(
public string $tagContent,
public DOMNode $domNode,
) {
}
/**
* Get the page ID that this tag references.
*/
public function getPageId(): int
{
return intval(trim(explode('#', $this->tagContent, 2)[0]));
}
/**
* Get the section ID that this tag references (if any)
*/
public function getSectionId(): string
{
return trim(explode('#', $this->tagContent, 2)[1] ?? '');
}
}

View File

@@ -202,6 +202,10 @@ class TrashCan
$attachmentService->deleteFile($attachment);
}
// Remove book template usages
Book::query()->where('default_template_id', '=', $page->id)
->update(['default_template_id' => null]);
$page->forceDelete();
return 1;

View File

@@ -9,6 +9,7 @@ use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\Exceptions\PostTooLargeException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException;
use Symfony\Component\ErrorHandler\Error\FatalError;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
@@ -42,7 +43,7 @@ class Handler extends ExceptionHandler
* If it returns a response, that will be provided back to the request
* upon an out of memory event.
*
* @var ?callable<?\Illuminate\Http\Response>
* @var ?callable(): ?Response
*/
protected $onOutOfMemory = null;

View File

@@ -9,6 +9,8 @@ use BookStack\Facades\Activity;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
abstract class Controller extends BaseController
@@ -165,4 +167,20 @@ abstract class Controller extends BaseController
{
return ['image_extension', 'mimes:jpeg,png,gif,webp', 'max:' . (config('app.upload_limit') * 1000)];
}
/**
* Redirect to the URL provided in the request as a '_return' parameter.
* Will check that the parameter leads to a URL under the root path of the system.
*/
protected function redirectToRequest(Request $request): RedirectResponse
{
$basePath = url('/');
$returnUrl = $request->input('_return') ?? $basePath;
if (!str_starts_with($returnUrl, $basePath)) {
return redirect($basePath);
}
return redirect($returnUrl);
}
}

View File

@@ -9,11 +9,9 @@ use Illuminate\Database\Eloquent\Builder;
class EntityPermissionEvaluator
{
protected string $action;
public function __construct(string $action)
{
$this->action = $action;
public function __construct(
protected string $action
) {
}
public function evaluateEntityForUser(Entity $entity, array $userRoleIds): ?bool
@@ -82,23 +80,25 @@ class EntityPermissionEvaluator
*/
protected function getPermissionsMapByTypeId(array $typeIdChain, array $filterRoleIds): array
{
$query = EntityPermission::query()->where(function (Builder $query) use ($typeIdChain) {
foreach ($typeIdChain as $typeId) {
$query->orWhere(function (Builder $query) use ($typeId) {
[$type, $id] = explode(':', $typeId);
$query->where('entity_type', '=', $type)
->where('entity_id', '=', $id);
});
$idsByType = [];
foreach ($typeIdChain as $typeId) {
[$type, $id] = explode(':', $typeId);
if (!isset($idsByType[$type])) {
$idsByType[$type] = [];
}
});
if (!empty($filterRoleIds)) {
$query->where(function (Builder $query) use ($filterRoleIds) {
$query->whereIn('role_id', [...$filterRoleIds, 0]);
});
$idsByType[$type][] = $id;
}
$relevantPermissions = $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
$relevantPermissions = [];
foreach ($idsByType as $type => $ids) {
$idsChunked = array_chunk($ids, 10000);
foreach ($idsChunked as $idChunk) {
$permissions = $this->getPermissionsForEntityIdsOfType($type, $idChunk, $filterRoleIds);
array_push($relevantPermissions, ...$permissions);
}
}
$map = [];
foreach ($relevantPermissions as $permission) {
@@ -113,6 +113,26 @@ class EntityPermissionEvaluator
return $map;
}
/**
* @param string[] $ids
* @param int[] $filterRoleIds
* @return EntityPermission[]
*/
protected function getPermissionsForEntityIdsOfType(string $type, array $ids, array $filterRoleIds): array
{
$query = EntityPermission::query()
->where('entity_type', '=', $type)
->whereIn('entity_id', $ids);
if (!empty($filterRoleIds)) {
$query->where(function (Builder $query) use ($filterRoleIds) {
$query->whereIn('role_id', [...$filterRoleIds, 0]);
});
}
return $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
}
/**
* @return string[]
*/

View File

@@ -83,13 +83,13 @@ class JointPermissionBuilder
$role->load('permissions');
// Chunk through all books
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
$this->bookFetchQuery()->chunk(10, function ($books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
Bookshelf::query()->select(['id', 'owned_by'])
->chunk(50, function ($shelves) use ($roles) {
->chunk(100, function ($shelves) use ($roles) {
$this->createManyJointPermissions($shelves->all(), $roles);
});
}

View File

@@ -25,7 +25,7 @@ class PermissionApplicator
/**
* Checks if an entity has a restriction set upon it.
*
* @param HasCreatorAndUpdater|HasOwner $ownable
* @param Model&(HasCreatorAndUpdater|HasOwner) $ownable
*/
public function checkOwnableUserAccess(Model $ownable, string $permission): bool
{
@@ -160,10 +160,9 @@ class PermissionApplicator
$joinQuery = function ($query) use ($entityProvider) {
$first = true;
/** @var Builder $query */
foreach ($entityProvider->all() as $entity) {
/** @var Builder $query */
$entityQuery = function ($query) use ($entity) {
/** @var Builder $query */
$query->select(['id', 'deleted_at'])
->selectRaw("'{$entity->getMorphClass()}' as type")
->from($entity->getTable())

View File

@@ -9,8 +9,7 @@ use BookStack\References\ModelResolvers\ChapterLinkModelResolver;
use BookStack\References\ModelResolvers\CrossLinkModelResolver;
use BookStack\References\ModelResolvers\PageLinkModelResolver;
use BookStack\References\ModelResolvers\PagePermalinkModelResolver;
use DOMDocument;
use DOMXPath;
use BookStack\Util\HtmlDocument;
class CrossLinkParser
{
@@ -54,13 +53,8 @@ class CrossLinkParser
{
$links = [];
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML($html);
$xPath = new DOMXPath($doc);
$anchors = $xPath->query('//a[@href]');
$doc = new HtmlDocument($html);
$anchors = $doc->queryXPath('//a[@href]');
/** @var \DOMElement $anchor */
foreach ($anchors as $anchor) {

View File

@@ -10,11 +10,9 @@ use BookStack\Http\Controller;
class ReferenceController extends Controller
{
protected ReferenceFetcher $referenceFetcher;
public function __construct(ReferenceFetcher $referenceFetcher)
{
$this->referenceFetcher = $referenceFetcher;
public function __construct(
protected ReferenceFetcher $referenceFetcher
) {
}
/**
@@ -23,7 +21,7 @@ class ReferenceController extends Controller
public function page(string $bookSlug, string $pageSlug)
{
$page = Page::getBySlugs($bookSlug, $pageSlug);
$references = $this->referenceFetcher->getPageReferencesToEntity($page);
$references = $this->referenceFetcher->getReferencesToEntity($page);
return view('pages.references', [
'page' => $page,
@@ -37,7 +35,7 @@ class ReferenceController extends Controller
public function chapter(string $bookSlug, string $chapterSlug)
{
$chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
$references = $this->referenceFetcher->getPageReferencesToEntity($chapter);
$references = $this->referenceFetcher->getReferencesToEntity($chapter);
return view('chapters.references', [
'chapter' => $chapter,
@@ -51,7 +49,7 @@ class ReferenceController extends Controller
public function book(string $slug)
{
$book = Book::getBySlug($slug);
$references = $this->referenceFetcher->getPageReferencesToEntity($book);
$references = $this->referenceFetcher->getReferencesToEntity($book);
return view('books.references', [
'book' => $book,
@@ -65,7 +63,7 @@ class ReferenceController extends Controller
public function shelf(string $slug)
{
$shelf = Bookshelf::getBySlug($slug);
$references = $this->referenceFetcher->getPageReferencesToEntity($shelf);
$references = $this->referenceFetcher->getReferencesToEntity($shelf);
return view('shelves.references', [
'shelf' => $shelf,

View File

@@ -3,65 +3,51 @@
namespace BookStack\References;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\Relation;
class ReferenceFetcher
{
protected PermissionApplicator $permissions;
public function __construct(PermissionApplicator $permissions)
{
$this->permissions = $permissions;
public function __construct(
protected PermissionApplicator $permissions,
protected MixedEntityListLoader $mixedEntityListLoader,
) {
}
/**
* Query and return the page references pointing to the given entity.
* Query and return the references pointing to the given entity.
* Loads the commonly required relations while taking permissions into account.
*/
public function getPageReferencesToEntity(Entity $entity): Collection
public function getReferencesToEntity(Entity $entity): Collection
{
$baseQuery = $this->queryPageReferencesToEntity($entity)
->with([
'from' => fn (Relation $query) => $query->select(Page::$listAttributes),
'from.book' => fn (Relation $query) => $query->scopes('visible'),
'from.chapter' => fn (Relation $query) => $query->scopes('visible'),
]);
$references = $this->permissions->restrictEntityRelationQuery(
$baseQuery,
'references',
'from_id',
'from_type'
)->get();
$references = $this->queryReferencesToEntity($entity)->get();
$this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from');
return $references;
}
/**
* Returns the count of page references pointing to the given entity.
* Returns the count of references pointing to the given entity.
* Takes permissions into account.
*/
public function getPageReferenceCountToEntity(Entity $entity): int
public function getReferenceCountToEntity(Entity $entity): int
{
$count = $this->permissions->restrictEntityRelationQuery(
$this->queryPageReferencesToEntity($entity),
return $this->queryReferencesToEntity($entity)->count();
}
protected function queryReferencesToEntity(Entity $entity): Builder
{
$baseQuery = Reference::query()
->where('to_type', '=', $entity->getMorphClass())
->where('to_id', '=', $entity->id);
return $this->permissions->restrictEntityRelationQuery(
$baseQuery,
'references',
'from_id',
'from_type'
)->count();
return $count;
}
protected function queryPageReferencesToEntity(Entity $entity): Builder
{
return Reference::query()
->where('to_type', '=', $entity->getMorphClass())
->where('to_id', '=', $entity->id)
->where('from_type', '=', (new Page())->getMorphClass());
);
}
}

View File

@@ -2,60 +2,62 @@
namespace BookStack\References;
use BookStack\Entities\Models\Page;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Collection;
class ReferenceStore
{
/**
* Update the outgoing references for the given page.
*/
public function updateForPage(Page $page): void
{
$this->updateForPages([$page]);
public function __construct(
protected EntityProvider $entityProvider
) {
}
/**
* Update the outgoing references for all pages in the system.
* Update the outgoing references for the given entity.
*/
public function updateForAllPages(): void
public function updateForEntity(Entity $entity): void
{
Reference::query()
->where('from_type', '=', (new Page())->getMorphClass())
->delete();
Page::query()->select(['id', 'html'])->chunk(100, function (Collection $pages) {
$this->updateForPages($pages->all());
});
$this->updateForEntities([$entity]);
}
/**
* Update the outgoing references for the pages in the given array.
* Update the outgoing references for all entities in the system.
*/
public function updateForAll(): void
{
Reference::query()->delete();
foreach ($this->entityProvider->all() as $entity) {
$entity->newQuery()->select(['id', $entity->htmlField])->chunk(100, function (Collection $entities) {
$this->updateForEntities($entities->all());
});
}
}
/**
* Update the outgoing references for the entities in the given array.
*
* @param Page[] $pages
* @param Entity[] $entities
*/
protected function updateForPages(array $pages): void
protected function updateForEntities(array $entities): void
{
if (count($pages) === 0) {
if (count($entities) === 0) {
return;
}
$parser = CrossLinkParser::createWithEntityResolvers();
$references = [];
$pageIds = array_map(fn (Page $page) => $page->id, $pages);
Reference::query()
->where('from_type', '=', $pages[0]->getMorphClass())
->whereIn('from_id', $pageIds)
->delete();
$this->dropReferencesFromEntities($entities);
foreach ($pages as $page) {
$models = $parser->extractLinkedModels($page->html);
foreach ($entities as $entity) {
$models = $parser->extractLinkedModels($entity->getAttribute($entity->htmlField));
foreach ($models as $model) {
$references[] = [
'from_id' => $page->id,
'from_type' => $page->getMorphClass(),
'from_id' => $entity->id,
'from_type' => $entity->getMorphClass(),
'to_id' => $model->id,
'to_type' => $model->getMorphClass(),
];
@@ -66,4 +68,29 @@ class ReferenceStore
Reference::query()->insert($referenceDataChunk);
}
}
/**
* Delete all the existing references originating from the given entities.
* @param Entity[] $entities
*/
protected function dropReferencesFromEntities(array $entities): void
{
$IdsByType = [];
foreach ($entities as $entity) {
$type = $entity->getMorphClass();
if (!isset($IdsByType[$type])) {
$IdsByType[$type] = [];
}
$IdsByType[$type][] = $entity->id;
}
foreach ($IdsByType as $type => $entityIds) {
Reference::query()
->where('from_type', '=', $type)
->whereIn('from_id', $entityIds)
->delete();
}
}
}

View File

@@ -4,32 +4,28 @@ namespace BookStack\References;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasHtmlDescription;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\RevisionRepo;
use DOMDocument;
use DOMXPath;
use BookStack\Util\HtmlDocument;
class ReferenceUpdater
{
protected ReferenceFetcher $referenceFetcher;
protected RevisionRepo $revisionRepo;
public function __construct(ReferenceFetcher $referenceFetcher, RevisionRepo $revisionRepo)
{
$this->referenceFetcher = $referenceFetcher;
$this->revisionRepo = $revisionRepo;
public function __construct(
protected ReferenceFetcher $referenceFetcher,
protected RevisionRepo $revisionRepo,
) {
}
public function updateEntityPageReferences(Entity $entity, string $oldLink)
public function updateEntityReferences(Entity $entity, string $oldLink): void
{
$references = $this->getReferencesToUpdate($entity);
$newLink = $entity->getUrl();
/** @var Reference $reference */
foreach ($references as $reference) {
/** @var Page $page */
$page = $reference->from;
$this->updateReferencesWithinPage($page, $oldLink, $newLink);
/** @var Entity $entity */
$entity = $reference->from;
$this->updateReferencesWithinEntity($entity, $oldLink, $newLink);
}
}
@@ -39,14 +35,15 @@ class ReferenceUpdater
protected function getReferencesToUpdate(Entity $entity): array
{
/** @var Reference[] $references */
$references = $this->referenceFetcher->getPageReferencesToEntity($entity)->values()->all();
$references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all();
if ($entity instanceof Book) {
$pages = $entity->pages()->get(['id']);
$chapters = $entity->chapters()->get(['id']);
$children = $pages->concat($chapters);
foreach ($children as $bookChild) {
$childRefs = $this->referenceFetcher->getPageReferencesToEntity($bookChild)->values()->all();
/** @var Reference[] $childRefs */
$childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all();
array_push($references, ...$childRefs);
}
}
@@ -60,7 +57,28 @@ class ReferenceUpdater
return array_values($deduped);
}
protected function updateReferencesWithinPage(Page $page, string $oldLink, string $newLink)
protected function updateReferencesWithinEntity(Entity $entity, string $oldLink, string $newLink): void
{
if ($entity instanceof Page) {
$this->updateReferencesWithinPage($entity, $oldLink, $newLink);
return;
}
if (in_array(HasHtmlDescription::class, class_uses($entity))) {
$this->updateReferencesWithinDescription($entity, $oldLink, $newLink);
}
}
protected function updateReferencesWithinDescription(Entity $entity, string $oldLink, string $newLink): void
{
/** @var HasHtmlDescription&Entity $entity */
$entity = (clone $entity)->refresh();
$html = $this->updateLinksInHtml($entity->description_html ?: '', $oldLink, $newLink);
$entity->description_html = $html;
$entity->save();
}
protected function updateReferencesWithinPage(Page $page, string $oldLink, string $newLink): void
{
$page = (clone $page)->refresh();
$html = $this->updateLinksInHtml($page->html, $oldLink, $newLink);
@@ -96,13 +114,8 @@ class ReferenceUpdater
return $html;
}
$html = '<body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$anchors = $xPath->query('//a[@href]');
$doc = new HtmlDocument($html);
$anchors = $doc->queryXPath('//a[@href]');
/** @var \DOMElement $anchor */
foreach ($anchors as $anchor) {
@@ -111,12 +124,6 @@ class ReferenceUpdater
$anchor->setAttribute('href', $updated);
}
$html = '';
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
foreach ($topElems as $child) {
$html .= $doc->saveHTML($child);
}
return $html;
return $doc->getBodyInnerHtml();
}
}

View File

@@ -2,6 +2,7 @@
namespace BookStack\Search;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\Popular;
use BookStack\Entities\Tools\SiblingFetcher;
use BookStack\Http\Controller;
@@ -82,6 +83,32 @@ class SearchController extends Controller
return view('search.parts.entity-selector-list', ['entities' => $entities, 'permission' => $permission]);
}
/**
* Search for a list of templates to choose from.
*/
public function templatesForSelector(Request $request)
{
$searchTerm = $request->get('term', false);
if ($searchTerm !== false) {
$searchOptions = SearchOptions::fromString($searchTerm);
$searchOptions->setFilter('is_template');
$entities = $this->searchRunner->searchEntities($searchOptions, 'page', 1, 20)['results'];
} else {
$entities = Page::visible()
->where('template', '=', true)
->where('draft', '=', false)
->orderBy('updated_at', 'desc')
->take(20)
->get(Page::$listAttributes);
}
return view('search.parts.entity-selector-list', [
'entities' => $entities,
'permission' => 'view'
]);
}
/**
* Search for a list of entities and return a partial HTML response of matching entities
* to be used as a result preview suggestion list for global system searches.

View File

@@ -6,7 +6,7 @@ use BookStack\Activity\Models\Tag;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use DOMDocument;
use BookStack\Util\HtmlDocument;
use DOMNode;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
@@ -138,16 +138,11 @@ class SearchIndex
'h6' => 1.5,
];
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
$html = str_ireplace(['<br>', '<br />', '<br/>'], "\n", $html);
$doc = new HtmlDocument($html);
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML($html);
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
/** @var DOMNode $child */
foreach ($topElems as $child) {
foreach ($doc->getBodyChildren() as $child) {
$nodeName = $child->nodeName;
$termCounts = $this->textToTermCountMap(trim($child->textContent));
foreach ($termCounts as $term => $count) {
@@ -168,7 +163,6 @@ class SearchIndex
*/
protected function generateTermScoreMapFromTags(array $tags): array
{
$scoreMap = [];
$names = [];
$values = [];

View File

@@ -170,6 +170,14 @@ class SearchOptions
return $parsed;
}
/**
* Set the value of a specific filter in the search options.
*/
public function setFilter(string $filterName, string $filterValue = ''): void
{
$this->filters[$filterName] = $filterValue;
}
/**
* Encode this instance to a search string.
*/

View File

@@ -58,7 +58,7 @@ class SearchRunner
$entityTypesToSearch = $entityTypes;
if ($entityType !== 'all') {
$entityTypesToSearch = $entityType;
$entityTypesToSearch = [$entityType];
} elseif (isset($searchOpts->filters['type'])) {
$entityTypesToSearch = explode('|', $searchOpts->filters['type']);
}
@@ -469,6 +469,13 @@ class SearchRunner
});
}
protected function filterIsTemplate(EloquentBuilder $query, Entity $model, $input)
{
if ($model instanceof Page) {
$query->where('template', '=', true);
}
}
protected function filterSortBy(EloquentBuilder $query, Entity $model, $input)
{
$functionName = Str::camel('sort_by_' . $input);

View File

@@ -87,7 +87,7 @@ class MaintenanceController extends Controller
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'regenerate-references');
try {
$referenceStore->updateForAllPages();
$referenceStore->updateForAll();
$this->showSuccessNotification(trans('settings.maint_regen_references_success'));
} catch (\Exception $exception) {
$this->showErrorNotification($exception->getMessage());

View File

@@ -50,7 +50,7 @@ class CustomHtmlHeadContentProvider
$hash = md5($content);
return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) {
return HtmlContentFilter::removeScripts($content);
return HtmlContentFilter::removeScriptsFromHtmlString($content);
});
}

View File

@@ -2,8 +2,6 @@
namespace BookStack\Theming;
use BookStack\Entities\Models\Page;
/**
* The ThemeEvents used within BookStack.
*
@@ -93,11 +91,30 @@ class ThemeEvents
*
* @param string $tagReference
* @param string $replacementHTML
* @param Page $currentPage
* @param ?Page $referencedPage
* @param \BookStack\Entities\Models\Page $currentPage
* @param ?\BookStack\Entities\Models\Page $referencedPage
*/
const PAGE_INCLUDE_PARSE = 'page_include_parse';
/**
* Routes register web event.
* Called when standard web (browser/non-api) app routes are registered.
* Provides an app router, so you can register your own web routes.
*
* @param \Illuminate\Routing\Router $router
*/
const ROUTES_REGISTER_WEB = 'routes_register_web';
/**
* Routes register web auth event.
* Called when auth-required web (browser/non-api) app routes can be registered.
* These are routes that typically require login to access (unless the instance is made public).
* Provides an app router, so you can register your own web routes.
*
* @param \Illuminate\Routing\Router $router
*/
const ROUTES_REGISTER_WEB_AUTH = 'routes_register_web_auth';
/**
* Web before middleware action.
* Runs before the request is handled but after all other middleware apart from those

View File

@@ -2,7 +2,7 @@
namespace BookStack\Theming;
use BookStack\Access\SocialAuthService;
use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\ThemeException;
use Illuminate\Console\Application;
use Illuminate\Console\Application as Artisan;
@@ -48,6 +48,14 @@ class ThemeService
return null;
}
/**
* Check if there are listeners registered for the given event name.
*/
public function hasListeners(string $event): bool
{
return count($this->listeners[$event] ?? []) > 0;
}
/**
* Register a new custom artisan command to be available.
*/
@@ -74,11 +82,11 @@ class ThemeService
}
/**
* @see SocialAuthService::addSocialDriver
* @see SocialDriverManager::addSocialDriver
*/
public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null): void
{
$socialAuthService = app()->make(SocialAuthService::class);
$socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);
$driverManager = app()->make(SocialDriverManager::class);
$driverManager->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);
}
}

View File

@@ -3,14 +3,13 @@
namespace BookStack\Uploads;
use Illuminate\Http\UploadedFile;
use Intervention\Image\ImageManager;
class FaviconHandler
{
protected string $path;
public function __construct(
protected ImageManager $imageTool
protected ImageResizer $imageResizer,
) {
$this->path = public_path('favicon.ico');
}
@@ -25,10 +24,8 @@ class FaviconHandler
}
$imageData = file_get_contents($file->getRealPath());
$image = $this->imageTool->make($imageData);
$image->resize(32, 32);
$bmpData = $image->encode('png');
$icoData = $this->pngToIco($bmpData, 32, 32);
$pngData = $this->imageResizer->resizeImageData($imageData, 32, 32, false, 'png');
$icoData = $this->pngToIco($pngData, 32, 32);
file_put_contents($this->path, $icoData);
}
@@ -81,7 +78,7 @@ class FaviconHandler
* Built following the file format info from Wikipedia:
* https://en.wikipedia.org/wiki/ICO_(file_format)
*/
protected function pngToIco(string $bmpData, int $width, int $height): string
protected function pngToIco(string $pngData, int $width, int $height): string
{
// ICO header
$header = pack('v', 0x00); // Reserved. Must always be 0
@@ -100,11 +97,11 @@ class FaviconHandler
// via intervention from png typically provides this as 24.
$entry .= pack('v', 0x00);
// Size of the image data in bytes
$entry .= pack('V', strlen($bmpData));
$entry .= pack('V', strlen($pngData));
// Offset of the bmp data from file start
$entry .= pack('V', strlen($header) + strlen($entry) + 4);
// Join & return the combined parts of the ICO image data
return $header . $entry . $bmpData;
return $header . $entry . $pngData;
}
}

View File

@@ -6,15 +6,14 @@ use BookStack\Exceptions\ImageUploadException;
use Exception;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Support\Facades\Cache;
use Intervention\Image\Gd\Driver;
use Intervention\Image\Image as InterventionImage;
use Intervention\Image\ImageManager;
class ImageResizer
{
protected const THUMBNAIL_CACHE_TIME = 604_800; // 1 week
public function __construct(
protected ImageManager $intervention,
protected ImageStorage $storage,
) {
}
@@ -105,13 +104,19 @@ class ImageResizer
/**
* Resize the image of given data to the specified size, and return the new image data.
* Format will remain the same as the input format, unless specified.
*
* @throws ImageUploadException
*/
public function resizeImageData(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
{
public function resizeImageData(
string $imageData,
?int $width,
?int $height,
bool $keepRatio,
?string $format = null,
): string {
try {
$thumb = $this->intervention->make($imageData);
$thumb = $this->interventionFromImageData($imageData);
} catch (Exception $e) {
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
}
@@ -127,7 +132,7 @@ class ImageResizer
$thumb->fit($width, $height);
}
$thumbData = (string) $thumb->encode();
$thumbData = (string) $thumb->encode($format);
// Use original image data if we're keeping the ratio
// and the resizing does not save any space.
@@ -138,6 +143,17 @@ class ImageResizer
return $thumbData;
}
/**
* Create an intervention image instance from the given image data.
* Performs some manual library usage to ensure image is specifically loaded
* from given binary data instead of data being misinterpreted.
*/
protected function interventionFromImageData(string $imageData): InterventionImage
{
$driver = new Driver();
return $driver->decoder->initFromBinary($imageData);
}
/**
* Orientate the given intervention image based upon the given original image data.
* Intervention does have an `orientate` method but the exif data it needs is lost before it

View File

@@ -154,7 +154,7 @@ class RoleController extends Controller
} catch (PermissionsException $e) {
$this->showErrorNotification($e->getMessage());
return redirect()->back();
return redirect("/settings/roles/delete/{$id}");
}
return redirect('/settings/roles');

View File

@@ -2,7 +2,7 @@
namespace BookStack\Users\Controllers;
use BookStack\Access\SocialAuthService;
use BookStack\Access\SocialDriverManager;
use BookStack\Http\Controller;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\UserNotificationPreferences;
@@ -161,7 +161,7 @@ class UserAccountController extends Controller
/**
* Show the view for the "Access & Security" account options.
*/
public function showAuth(SocialAuthService $socialAuthService)
public function showAuth(SocialDriverManager $socialDriverManager)
{
$mfaMethods = user()->mfaValues()->get()->groupBy('method');
@@ -171,7 +171,7 @@ class UserAccountController extends Controller
'category' => 'auth',
'mfaMethods' => $mfaMethods,
'authMethod' => config('auth.method'),
'activeSocialDrivers' => $socialAuthService->getActiveDrivers(),
'activeSocialDrivers' => $socialDriverManager->getActive(),
]);
}

View File

@@ -90,7 +90,7 @@ class UserApiController extends ApiController
public function create(Request $request)
{
$data = $this->validate($request, $this->rules()['create']);
$sendInvite = ($data['send_invite'] ?? false) === true;
$sendInvite = boolval($data['send_invite'] ?? false) === true;
$user = null;
DB::transaction(function () use ($data, $sendInvite, &$user) {

View File

@@ -2,7 +2,7 @@
namespace BookStack\Users\Controllers;
use BookStack\Access\SocialAuthService;
use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Http\Controller;
@@ -101,7 +101,7 @@ class UserController extends Controller
/**
* Show the form for editing the specified user.
*/
public function edit(int $id, SocialAuthService $socialAuthService)
public function edit(int $id, SocialDriverManager $socialDriverManager)
{
$this->checkPermission('users-manage');
@@ -109,7 +109,7 @@ class UserController extends Controller
$user->load(['apiTokens', 'mfaValues']);
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
$activeSocialDrivers = $socialDriverManager->getActive();
$mfaMethods = $user->mfaValues->groupBy('method');
$this->setPageTitle(trans('settings.user_profile'));
$roles = Role::query()->orderBy('display_name', 'asc')->get();

View File

@@ -3,9 +3,6 @@
namespace BookStack\Users\Controllers;
use BookStack\Http\Controller;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Settings\UserShortcutMap;
use BookStack\Users\UserRepo;
use Illuminate\Http\Request;
@@ -23,7 +20,7 @@ class UserPreferencesController extends Controller
{
$valueViewTypes = ['books', 'bookshelves', 'bookshelf'];
if (!in_array($type, $valueViewTypes)) {
return redirect()->back(500);
return $this->redirectToRequest($request);
}
$view = $request->get('view');
@@ -34,7 +31,7 @@ class UserPreferencesController extends Controller
$key = $type . '_view_type';
setting()->putForCurrentUser($key, $view);
return redirect()->back(302, [], "/");
return $this->redirectToRequest($request);
}
/**
@@ -44,7 +41,7 @@ class UserPreferencesController extends Controller
{
$validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks', 'tags', 'page_revisions'];
if (!in_array($type, $validSortTypes)) {
return redirect()->back(500);
return $this->redirectToRequest($request);
}
$sort = substr($request->get('sort') ?: 'name', 0, 50);
@@ -55,18 +52,18 @@ class UserPreferencesController extends Controller
setting()->putForCurrentUser($sortKey, $sort);
setting()->putForCurrentUser($orderKey, $order);
return redirect()->back(302, [], "/");
return $this->redirectToRequest($request);
}
/**
* Toggle dark mode for the current user.
*/
public function toggleDarkMode()
public function toggleDarkMode(Request $request)
{
$enabled = setting()->getForCurrentUser('dark-mode-enabled');
setting()->putForCurrentUser('dark-mode-enabled', $enabled ? 'false' : 'true');
return redirect()->back();
return $this->redirectToRequest($request);
}
/**

View File

@@ -160,10 +160,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function can(string $permissionName): bool
{
if ($this->email === 'guest') {
return false;
}
return $this->permissions()->contains($permissionName);
}

View File

@@ -3,70 +3,65 @@
namespace BookStack\Util;
use DOMAttr;
use DOMDocument;
use DOMElement;
use DOMNodeList;
use DOMXPath;
class HtmlContentFilter
{
/**
* Remove all the script elements from the given HTML.
* Remove all the script elements from the given HTML document.
*/
public static function removeScripts(string $html): string
public static function removeScriptsFromDocument(HtmlDocument $doc)
{
if (empty($html)) {
return $html;
}
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML($html);
$xPath = new DOMXPath($doc);
// Remove standard script tags
$scriptElems = $xPath->query('//script');
$scriptElems = $doc->queryXPath('//script');
static::removeNodes($scriptElems);
// Remove clickable links to JavaScript URI
$badLinks = $xPath->query('//*[' . static::xpathContains('@href', 'javascript:') . ']');
$badLinks = $doc->queryXPath('//*[' . static::xpathContains('@href', 'javascript:') . ']');
static::removeNodes($badLinks);
// Remove forms with calls to JavaScript URI
$badForms = $xPath->query('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
$badForms = $doc->queryXPath('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
static::removeNodes($badForms);
// Remove meta tag to prevent external redirects
$metaTags = $xPath->query('//meta[' . static::xpathContains('@content', 'url') . ']');
$metaTags = $doc->queryXPath('//meta[' . static::xpathContains('@content', 'url') . ']');
static::removeNodes($metaTags);
// Remove data or JavaScript iFrames
$badIframes = $xPath->query('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
$badIframes = $doc->queryXPath('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
static::removeNodes($badIframes);
// Remove attributes, within svg children, hiding JavaScript or data uris.
// A bunch of svg element and attribute combinations expose xss possibilities.
// For example, SVG animate tag can exploit javascript in values.
$badValuesAttrs = $xPath->query('//svg//@*[' . static::xpathContains('.', 'data:') . '] | //svg//@*[' . static::xpathContains('.', 'javascript:') . ']');
$badValuesAttrs = $doc->queryXPath('//svg//@*[' . static::xpathContains('.', 'data:') . '] | //svg//@*[' . static::xpathContains('.', 'javascript:') . ']');
static::removeAttributes($badValuesAttrs);
// Remove elements with a xlink:href attribute
// Used in SVG but deprecated anyway, so we'll be a bit more heavy-handed here.
$xlinkHrefAttributes = $xPath->query('//@*[contains(name(), \'xlink:href\')]');
$xlinkHrefAttributes = $doc->queryXPath('//@*[contains(name(), \'xlink:href\')]');
static::removeAttributes($xlinkHrefAttributes);
// Remove 'on*' attributes
$onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
$onAttributes = $doc->queryXPath('//@*[starts-with(name(), \'on\')]');
static::removeAttributes($onAttributes);
}
$html = '';
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
foreach ($topElems as $child) {
$html .= $doc->saveHTML($child);
/**
* Remove scripts from the given HTML string.
*/
public static function removeScriptsFromHtmlString(string $html): string
{
if (empty($html)) {
return $html;
}
return $html;
$doc = new HtmlDocument($html);
static::removeScriptsFromDocument($doc);
return $doc->getBodyInnerHtml();
}
/**

View File

@@ -0,0 +1,79 @@
<?php
namespace BookStack\Util;
use DOMAttr;
use DOMElement;
use DOMNamedNodeMap;
use DOMNode;
/**
* Filter to ensure HTML input for description content remains simple and
* to a limited allow-list of elements and attributes.
* More for consistency and to prevent nuisance rather than for security
* (which would be done via a separate content filter and CSP).
*/
class HtmlDescriptionFilter
{
/**
* @var array<string, string[]>
*/
protected static array $allowedAttrsByElements = [
'p' => [],
'a' => ['href', 'title'],
'ol' => [],
'ul' => [],
'li' => [],
'strong' => [],
'em' => [],
'br' => [],
];
public static function filterFromString(string $html): string
{
if (empty(trim($html))) {
return '';
}
$doc = new HtmlDocument($html);
$topLevel = [...$doc->getBodyChildren()];
foreach ($topLevel as $child) {
/** @var DOMNode $child */
if ($child instanceof DOMElement) {
static::filterElement($child);
} else {
$child->parentNode->removeChild($child);
}
}
return $doc->getBodyInnerHtml();
}
protected static function filterElement(DOMElement $element): void
{
$elType = strtolower($element->tagName);
$allowedAttrs = static::$allowedAttrsByElements[$elType] ?? null;
if (is_null($allowedAttrs)) {
$element->remove();
return;
}
/** @var DOMNamedNodeMap $attrs */
$attrs = $element->attributes;
for ($i = $attrs->length - 1; $i >= 0; $i--) {
/** @var DOMAttr $attr */
$attr = $attrs->item($i);
$name = strtolower($attr->name);
if (!in_array($name, $allowedAttrs)) {
$element->removeAttribute($attr->name);
}
}
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
static::filterElement($child);
}
}
}
}

152
app/Util/HtmlDocument.php Normal file
View File

@@ -0,0 +1,152 @@
<?php
namespace BookStack\Util;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMNodeList;
use DOMXPath;
/**
* HtmlDocument is a thin wrapper around DOMDocument built
* specifically for loading, querying and generating HTML content.
*/
class HtmlDocument
{
protected DOMDocument $document;
protected ?DOMXPath $xpath = null;
protected int $loadOptions;
public function __construct(string $partialHtml = '', int $loadOptions = 0)
{
libxml_use_internal_errors(true);
$this->document = new DOMDocument();
$this->loadOptions = $loadOptions;
if ($partialHtml) {
$this->loadPartialHtml($partialHtml);
}
}
/**
* Load some HTML content that's part of a document (e.g. body content)
* into the current document.
*/
public function loadPartialHtml(string $html): void
{
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
$this->document->loadHTML($html, $this->loadOptions);
$this->xpath = null;
}
/**
* Load a complete page of HTML content into the document.
*/
public function loadCompleteHtml(string $html): void
{
$html = '<?xml encoding="utf-8" ?>' . $html;
$this->document->loadHTML($html, $this->loadOptions);
$this->xpath = null;
}
/**
* Start an XPath query on the current document.
*/
public function queryXPath(string $expression): DOMNodeList
{
if (is_null($this->xpath)) {
$this->xpath = new DOMXPath($this->document);
}
$result = $this->xpath->query($expression);
if ($result === false) {
throw new \InvalidArgumentException("XPath query for expression [$expression] failed to execute");
}
return $result;
}
/**
* Create a new DOMElement instance within the document.
*/
public function createElement(string $localName, string $value = ''): DOMElement
{
$element = $this->document->createElement($localName, $value);
if ($element === false) {
throw new \InvalidArgumentException("Failed to create element of name [$localName] and value [$value]");
}
return $element;
}
/**
* Get an element within the document of the given ID.
*/
public function getElementById(string $elementId): ?DOMElement
{
return $this->document->getElementById($elementId);
}
/**
* Get the DOMNode that represents the HTML body.
*/
public function getBody(): DOMNode
{
return $this->document->getElementsByTagName('body')[0];
}
/**
* Get the nodes that are a direct child of the body.
* This is usually all the content nodes if loaded partially.
*/
public function getBodyChildren(): DOMNodeList
{
return $this->getBody()->childNodes;
}
/**
* Get the inner HTML content of the body.
* This is usually all the content if loaded partially.
*/
public function getBodyInnerHtml(): string
{
$html = '';
foreach ($this->getBodyChildren() as $child) {
$html .= $this->document->saveHTML($child);
}
return $html;
}
/**
* Get the HTML content of the whole document.
*/
public function getHtml(): string
{
return $this->document->saveHTML($this->document->documentElement);
}
/**
* Get the inner HTML for the given node.
*/
public function getNodeInnerHtml(DOMNode $node): string
{
$html = '';
foreach ($node->childNodes as $childNode) {
$html .= $this->document->saveHTML($childNode);
}
return $html;
}
/**
* Get the outer HTML for the given node.
*/
public function getNodeOuterHtml(DOMNode $node): string
{
return $this->document->saveHTML($node);
}
}

View File

@@ -2,14 +2,12 @@
namespace BookStack\Util;
use DOMDocument;
use DOMElement;
use DOMNodeList;
use DOMXPath;
class HtmlNonceApplicator
{
protected static $placeholder = '[CSP_NONCE_VALUE]';
protected static string $placeholder = '[CSP_NONCE_VALUE]';
/**
* Prepare the given HTML content with nonce attributes including a placeholder
@@ -21,28 +19,20 @@ class HtmlNonceApplicator
return $html;
}
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML($html, LIBXML_SCHEMA_CREATE);
$xPath = new DOMXPath($doc);
// LIBXML_SCHEMA_CREATE was found to be required here otherwise
// the PHP DOMDocument handling will attempt to format/close
// HTML tags within scripts and therefore change JS content.
$doc = new HtmlDocument($html, LIBXML_SCHEMA_CREATE);
// Apply to scripts
$scriptElems = $xPath->query('//script');
$scriptElems = $doc->queryXPath('//script');
static::addNonceAttributes($scriptElems, static::$placeholder);
// Apply to styles
$styleElems = $xPath->query('//style');
$styleElems = $doc->queryXPath('//style');
static::addNonceAttributes($styleElems, static::$placeholder);
$returnHtml = '';
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
foreach ($topElems as $child) {
$content = $doc->saveHTML($child);
$returnHtml .= $content;
}
return $returnHtml;
return $doc->getBodyInnerHtml();
}
/**

View File

@@ -23,7 +23,7 @@
"guzzlehttp/guzzle": "^7.4",
"intervention/image": "^2.7",
"laravel/framework": "^9.0",
"laravel/socialite": "^5.8",
"laravel/socialite": "^5.10",
"laravel/tinker": "^2.6",
"league/commonmark": "^2.3",
"league/flysystem-aws-s3-v3": "^3.0",
@@ -38,18 +38,18 @@
"socialiteproviders/microsoft-azure": "^5.1",
"socialiteproviders/okta": "^4.2",
"socialiteproviders/twitch": "^5.3",
"ssddanbrown/htmldiff": "^1.0.2"
"ssddanbrown/htmldiff": "^1.0.2",
"ssddanbrown/symfony-mailer": "6.0.x-dev"
},
"require-dev": {
"fakerphp/faker": "^1.21",
"itsgoingd/clockwork": "^5.1",
"mockery/mockery": "^1.5",
"nunomaduro/collision": "^6.4",
"nunomaduro/larastan": "^2.4",
"larastan/larastan": "^2.7",
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "^3.7",
"ssddanbrown/asserthtml": "^2.0",
"ssddanbrown/symfony-mailer": "6.0.x-dev"
"ssddanbrown/asserthtml": "^2.0"
},
"autoload": {
"psr-4": {

790
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,10 +21,12 @@ class BookFactory extends Factory
*/
public function definition()
{
$description = $this->faker->paragraph();
return [
'name' => $this->faker->sentence(),
'slug' => Str::random(10),
'description' => $this->faker->paragraph(),
'description' => $description,
'description_html' => '<p>' . e($description) . '</p>'
];
}
}

View File

@@ -21,10 +21,12 @@ class BookshelfFactory extends Factory
*/
public function definition()
{
$description = $this->faker->paragraph();
return [
'name' => $this->faker->sentence,
'slug' => Str::random(10),
'description' => $this->faker->paragraph,
'description' => $description,
'description_html' => '<p>' . e($description) . '</p>'
];
}
}

View File

@@ -21,10 +21,12 @@ class ChapterFactory extends Factory
*/
public function definition()
{
$description = $this->faker->paragraph();
return [
'name' => $this->faker->sentence(),
'slug' => Str::random(10),
'description' => $this->faker->paragraph(),
'description' => $description,
'description_html' => '<p>' . e($description) . '</p>'
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddDefaultTemplateToBooks extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('books', function (Blueprint $table) {
$table->integer('default_template_id')->nullable()->default(null);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('books', function (Blueprint $table) {
$table->dropColumn('default_template_id');
});
}
}

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$addColumn = fn(Blueprint $table) => $table->text('description_html');
Schema::table('books', $addColumn);
Schema::table('chapters', $addColumn);
Schema::table('bookshelves', $addColumn);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
$removeColumn = fn(Blueprint $table) => $table->removeColumn('description_html');
Schema::table('books', $removeColumn);
Schema::table('chapters', $removeColumn);
Schema::table('bookshelves', $removeColumn);
}
};

View File

@@ -3,6 +3,7 @@
namespace Database\Seeders;
use BookStack\Api\ApiToken;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
@@ -38,7 +39,7 @@ class DummyContentSeeder extends Seeder
$byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'owned_by' => $editorUser->id];
\BookStack\Entities\Models\Book::factory()->count(5)->create($byData)
Book::factory()->count(5)->create($byData)
->each(function ($book) use ($byData) {
$chapters = Chapter::factory()->count(3)->create($byData)
->each(function ($chapter) use ($book, $byData) {
@@ -50,7 +51,7 @@ class DummyContentSeeder extends Seeder
$book->pages()->saveMany($pages);
});
$largeBook = \BookStack\Entities\Models\Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
$largeBook = Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
$pages = Page::factory()->count(200)->make($byData);
$chapters = Chapter::factory()->count(50)->make($byData);
$largeBook->pages()->saveMany($pages);

View File

@@ -28,12 +28,18 @@ class LargeContentSeeder extends Seeder
/** @var Book $largeBook */
$largeBook = Book::factory()->create(['name' => 'Large book' . Str::random(10), 'created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
$pages = Page::factory()->count(200)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
$chapters = Chapter::factory()->count(50)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
$largeBook->pages()->saveMany($pages);
$largeBook->chapters()->saveMany($chapters);
$all = array_merge([$largeBook], array_values($pages->all()), array_values($chapters->all()));
$allPages = [];
foreach ($chapters as $chapter) {
$pages = Page::factory()->count(100)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'chapter_id' => $chapter->id]);
$largeBook->pages()->saveMany($pages);
array_push($allPages, ...$pages->all());
}
$all = array_merge([$largeBook], $allPages, array_values($chapters->all()));
app()->make(JointPermissionBuilder::class)->rebuildForEntity($largeBook);
app()->make(SearchIndex::class)->indexEntities($all);

View File

@@ -1,4 +1,9 @@
{
"name": "My own book",
"description": "This is my own little book"
"description_html": "<p>This is <strong>my</strong> own little book created via the API</p>",
"default_template_id": 2427,
"tags": [
{"name": "Category", "value": "Top Content"},
{"name": "Rating", "value": "Highest"}
]
}

View File

@@ -1,4 +1,8 @@
{
"name": "My updated book",
"description": "This is my book with updated details"
"description_html": "<p>This is my book with <em>updated</em> details</p>",
"default_template_id": 2427,
"tags": [
{"name": "Subject", "value": "Updates"}
]
}

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