Compare commits

...

277 Commits

Author SHA1 Message Date
Dan Brown
77fc37ac25 Updated version and assets for release v24.05 2024-05-11 15:49:29 +01:00
Dan Brown
3424351e84 Merge branch 'development' into release 2024-05-11 15:48:49 +01:00
Dan Brown
a3a776d4a6 Updated translator & dependency attribution before release v24.05 2024-05-11 15:47:38 +01:00
Dan Brown
2b9b0f91cb Updated translations with latest Crowdin changes (#4890) 2024-05-11 15:15:10 +01:00
Dan Brown
424e8f503e Readme: Updated sponsor list 2024-05-10 11:02:20 +01:00
Dan Brown
d206129f3d Deps: Updated composer dependencies 2024-05-05 16:30:04 +01:00
Dan Brown
baad7fa9cb Merge pull request #4987 from BookStackApp/audit_api
Addition of Audit Log API Endpoint
2024-05-05 16:14:09 +01:00
Dan Brown
d54c7b4783 Audit Log: Fixed bad reference to linked entity item 2024-05-05 16:05:21 +01:00
Dan Brown
67df127c26 API: Added to, and updated, testing to cover audit log additions 2024-05-05 15:44:58 +01:00
Dan Brown
3946158e88 API: Added audit log list endpoint
Not yested covered with testing.
Changes database columns for more presentable names and for future use
to connect additional model types.
For #4316
2024-05-04 16:28:18 +01:00
Dan Brown
dd251d9e62 Merge branch 'nesges/development' into development 2024-05-04 14:00:40 +01:00
Dan Brown
5c28bcf865 Registration: Reviewed added simple honeypot, added testing
Also cleaned up old RegistrationController syntax.
Review of #4970
2024-05-04 13:59:41 +01:00
Dan Brown
7b3b28d3f8 Merge pull request #4972 from johnroyer/fix-typo-in-language-file
remove space at the beginning of description
2024-05-03 19:16:23 +01:00
Dan Brown
20e86bf376 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2024-05-03 13:40:18 +01:00
Dan Brown
f9e087330b WYSIWYG: Added text direction support for code editor popup
Editor popup will now reflect the direction of the opened code block.
This also updates in-editor codemirror instances to correcly reflect/use
the direction if set on the inner code elem.

This also defaults new code blocks, when in RTL languages, to be started
in LTR, which can then be changed via in-editor direction controls if
needed. This is on the assumption that most code will be LTR (could not
find much examples of RTL code use).

Fixes #4943
2024-05-03 13:40:00 +01:00
Dan Brown
b0720777be Merge pull request #4985 from BookStackApp/ldap_ca_cert_control
LDAP CA TLS Cert Option, PR Review and continuation
2024-05-02 23:16:16 +01:00
Dan Brown
8087123f2e LDAP: Review, testing and update of LDAP TLS CA cert control
Review of #4913
Added testing to cover option.
Updated option so it can be used for a CA directory, or a CA file.
Updated option name to be somewhat abstracted from original underling
PHP option.

Tested against Jumpcloud.
Testing took hours due to instability which was due to these settings
sticking and being unstable on change until php process restart.
Also due to little documentation for these options.
X_TLS_CACERTDIR option needs cert files to be named via specific hashes
which can be achieved via c_rehash utility.

This also adds detail on STARTTLS failure, which took a long time to
discover due to little detail out there for deeper PHP LDAP debugging.
2024-05-02 23:11:31 +01:00
Dan Brown
4c1c315594 WYSWIYG: Fixed misaligned table cell p line height
Removes an editor-specific line-height which was overriding cell
paragraph line height, causing mis-aligned style compared to viewing.
Checked a range of styles and looked at history, could not see original
purpose of the line-height removed here.
Closes #4960
2024-05-02 15:20:51 +01:00
Dan Brown
f95fb640af WYSWIYG: Improved use of object tags to embed content
- Prevented image toolbars showing for objects embeds due to tinymce
  image placeholder, and added media toolbar.
- Fixed height of object embed placeholder being forced to auto
  when in the editor, allowing height attributed to be properly
  reflected as it would on normal page view.

Closes #4974
2024-05-01 17:22:53 +01:00
Dan Brown
493d8027cd Attachments: Fixed drag into editor in Chrome
Seemed to be chrome specific from testing.
Required editors to have preventDefault called on dragover.
Tested in Chrome, FF, & Safari.
Tested in both editors, and re-tested text/image drop to ensure still
works.

Fixed #4975
2024-04-29 19:21:13 +01:00
Dan Brown
06bb55184c WYSIWYG: Fixed unexpected clearing of table cell styles
Fixes custom table cell clear-format handling since it was being called
on many format removals, not just the clear-formatting action.
This updates the code to specifically run on the RemoveFormat action
which is triggered by the clear formatting button.
Fixes #4964
2024-04-29 17:47:06 +01:00
Dan Brown
6b681961e5 LDAP: Updated default user filter placeholder format
To not conflict with env variables, and to align with placeholders used
for PDF gen command.
Added test to cover, including old format supported for
back-compatibility.
For #4967
2024-04-28 12:29:57 +01:00
Dan Brown
e1149a27e9 Merge pull request #4969 from BookStackApp/pdf_command_option
PDF Exports: New command option and library/option cleanup
2024-04-26 17:06:38 +01:00
Dan Brown
f0dd33c1b4 PDF: Added tests for pdf command, fixed old tests for changes 2024-04-26 15:39:40 +01:00
Zero
5860e1e2ce remove space at the beginning of description 2024-04-25 13:35:36 +08:00
Dan Brown
1c7128c2cb PDF: Added implmentation of command PDF option
Tested quickly manually but not yet covered by PHPUnit tests.
2024-04-24 16:09:53 +01:00
Dan Brown
40200856af PDF: Removed barryvdh snappy to use snappy direct
Also simplifies config format, and updates snappy implmentation to use
the new config file.
Not yet tested.
2024-04-24 15:13:44 +01:00
Dan Brown
bb6670d395 PDF: Started new command option, merged options, simplified dompdf
- Updated DOMPDF to direcly use library instead of depending on barry
wrapper.
- Merged existing export options file into single exports file.
- Defined option for new command option.

Related to #4732
2024-04-22 16:40:42 +01:00
nesges
0d2a268be0 whitespace only 2024-04-21 17:44:01 +02:00
nesges
16399b63be better accessibility for honepot formfield 2024-04-21 16:08:28 +02:00
Dan Brown
d949b97cc1 Merge pull request #4955 from BookStackApp/oidc_userinfo
OIDC userinfo endpoint support
2024-04-19 16:55:29 +01:00
Dan Brown
8b14a701a4 OIDC Userinfo: Fixed issues with validation logic from changes
Also updated test to suit validation changes
2024-04-19 16:43:51 +01:00
Dan Brown
0958909cd9 OIDC Userinfo: Added additional tests to cover jwks usage 2024-04-19 15:05:00 +01:00
Dan Brown
b18cee3dc4 OIDC Userinfo: Added JWT signed response support
Not yet tested, nor checked all response validations.
2024-04-19 14:12:27 +01:00
nesges
31272e60b6 add ambrosia-container to registration form as honeypot for bots: new form field "username" must not be filled 2024-04-19 09:35:09 +02:00
nesges
1b1cb18839 fixed mislabeling of name input 2024-04-19 09:18:34 +02:00
Dan Brown
fa543bbd4d OIDC Userinfo: Started writing tests to cover userinfo calling 2024-04-17 23:26:56 +01:00
Dan Brown
7d7cd32ca7 OIDC Userinfo: Added userinfo data validation, seperated from id token
Wrapped userinfo response in its own class for additional handling and
validation.
Updated userdetails to take abstract claim data, to be populated by
either userinfo data or id token data.
2024-04-17 18:23:58 +01:00
Dan Brown
a71c8c60b7 OIDC: Extracted user detail handling to own OidcUserDetails class
Allows a proper defined object instead of an array an extracts related
logic out of OidcService.
Updated userinfo to only be called if we're missing details.
2024-04-16 18:14:22 +01:00
Dan Brown
9183e7f2fe OIDC Userinfo: Labelled changes to be made during review 2024-04-16 15:52:55 +01:00
Dan Brown
d640411adb OIDC: Cleaned up provider settings, added extra validation
- Added endpoint validation to ensure HTTPS as per spec
- Added some missing types
- Removed redirectUri from OidcProviderSettings since it's not a
  provider-based setting, but a setting for the oauth client, so
  extracted that back to service.
2024-04-16 15:19:51 +01:00
Dan Brown
dc6013fd7e Merge branch 'development' into lukeshu/oidc-development 2024-04-16 14:57:36 +01:00
Dan Brown
80ac66e0a6 Code Editor: Added scala to language list
For #4953
2024-04-16 14:44:17 +01:00
Dan Brown
f05ec4cc26 Tags: Stopped recycle bin tags being counted on index
For #4892
Added test to cover.
2024-04-15 18:44:59 +01:00
Dan Brown
d9ff001ffe Merge pull request #4904 from C0rn3j/optimize-images
15KB lossless optimization via oxipng(PNG) and svgo(SVG)
2024-04-15 18:07:29 +01:00
Dan Brown
0f6cb9ed84 Content styles: Made links underlined for visibility
Inline with A11y recommendations where color may not be reliable on its
own.
Tested various content link scenarios across chrome, safari & FF.
For #4939
2024-04-13 15:48:39 +01:00
Dan Brown
dde1f27882 Merge pull request #4930 from BookStackApp/split_md_js
JS Build: Split markdown to own file, updated packages
2024-04-08 14:46:06 +01:00
Dan Brown
f5e6f9574d JS Build: Split markdown to own file, updated packages
Markdown-related code was growing, representing half of app.js main
bundle code while only being needed in one view/scenario.
This extracts markdown related code to its own built file.
Related to #4858
2024-04-08 14:41:51 +01:00
Dan Brown
ee40adf11a Merge pull request #4921 from BookStackApp/v24-02
v23.02.3 changes
2024-04-05 15:21:05 +01:00
Dan Brown
606f9d92d0 Updated version and assets for release v24.02.3 2024-04-05 15:20:08 +01:00
Dan Brown
a5e25abb9c Merge branch 'v24-02' into release 2024-04-05 15:19:34 +01:00
Dan Brown
3e23f456fe CSS: Removed redundant calc 2024-04-05 15:18:58 +01:00
Dan Brown
b9e2d33ed4 Page Content: Aligned max-width across viewer and editors
For #4916
2024-04-05 15:06:08 +01:00
Dan Brown
19f78dbe6c WYSIWYG descriptions: Allowed anchor target attrs
Allowed since this is a control in the editor UI, but would previously
be stripped by editor config & server-side filtering.
For #4925
2024-04-03 16:46:53 +01:00
Dan Brown
a33dbcb04a References: Fixed references count/list recycle bin interaction
Count and reference list would get references then attempt to load
entities, which could fail to load if in the recycle bin.
This updates the queries to effectively ignore references for items we
can't see (in recycle bin).
Added test to cover.

For #4918
2024-04-01 17:08:53 +01:00
Dan Brown
58f6219cb3 Code: Fixed highlighting issues when no code language set
For #4917
2024-03-31 14:33:08 +01:00
Matt Moore
18269f2c60 Add LDAP_TLS_CACERTFILE to example env file 2024-03-27 13:17:25 +00:00
Matt Moore
06ef95dc5f Change to allow override of CA CERT for LDAPS
Using the env LDAP_TLS_CACERTFILE to set a file to use to override
the CA CERT used to verify LDAPS connections. This is to make this
process easier for docker use.
2024-03-26 16:30:04 +00:00
Martin Rys
76c7166268 Use zopfli for oxipng for extra 3KB~ 2024-03-26 12:31:54 +01:00
Dan Brown
6c063f424c Merge pull request #4907 from BookStackApp/licensing_update
Dependency Licensing Improvements
2024-03-24 12:01:01 +00:00
Dan Brown
3345680f7d Licensing: Added license gen as composer command 2024-03-24 11:58:31 +00:00
Dan Brown
a2fd80954b Licensing: Added links and tests for new licenses endpoint
For #4907
2024-03-23 22:04:18 +00:00
Dan Brown
0c524c7c8f Licensing: Added licenses app view
Extracted many methods to a new "MetaController" in the process.
2024-03-23 16:31:13 +00:00
Martin Rys
5f306a11e7 15KB lossless optimization via oxipng(PNG) and svgo(SVG) 2024-03-23 16:33:11 +01:00
Dan Brown
ed956a4cf0 Licensing: Updated license gen scripts to share logic 2024-03-23 15:33:05 +00:00
Dan Brown
55a2a6db88 Licensing: Added script to gen info for JS packages 2024-03-23 15:19:58 +00:00
Dan Brown
f789359886 Licensing: Added script to build PHP library licensing information 2024-03-22 14:44:23 +00:00
Dan Brown
c221a00e1e Migrations: Added prefix support to schema inspection 2024-03-19 10:30:26 +00:00
Dan Brown
83913af68b Merge branch 'development' into C0rn3j/development 2024-03-18 14:35:16 +00:00
Dan Brown
fa5395a02b Meta: Updated workflows, licence and readme
- Updated license year
- Updated some readme wording, removed lapsed sponsor, Removed twitter
  link, added link to alt github source
- Update cache action for GH workflows since GH was complaining
2024-03-18 14:26:31 +00:00
Dan Brown
85dd71507e Merge pull request #4903 from BookStackApp/laravel10
Framework: Upgrade from Laravel 9 to 10
2024-03-17 17:00:03 +00:00
Dan Brown
28d6292278 Framework: Addressed deprecations 2024-03-17 16:52:19 +00:00
Dan Brown
b4b84f81a0 Deps: Updated custom symfony/mailer package
Done during #4903 work
2024-03-17 16:32:59 +00:00
Dan Brown
2345fd4677 Deps: Updated intervention library from 2 to 3
Major version change, required some changes to API
For #4903
2024-03-17 16:03:12 +00:00
Dan Brown
3250fc732c Testing: Updated PHPUnit from 9 to 10
For #4903
2024-03-17 15:41:11 +00:00
Dan Brown
45d52f27ae Migrations: Updated with type hints instead of php doc
Also updated code to properly import used facades.
For #4903
2024-03-17 15:29:09 +00:00
Dan Brown
d6b7717985 Framework: Fixed issues breaking tests
For #4903
2024-03-16 15:26:34 +00:00
Dan Brown
794671ef32 Framework: Upgrade from Laravel 9 to 10
Following Laravel guidance and GitHub diff.
Not yet in tested state with app-specific changes made.
2024-03-16 15:12:14 +00:00
Martin Rys
70479df5dc Dockerfile: Don't cache 50MB of lists and use a single layer, make it pretty 2024-03-12 14:04:33 +01:00
Dan Brown
07761524af Dev: Fixed flaky OIDC test, updated dev version 2024-03-12 12:08:26 +00:00
Dan Brown
2ed931aeed Updated minimum PHP version from 8.0 to 8.1
For #4893
2024-03-12 11:29:51 +00:00
Dan Brown
b310e87e4c Updated version and assets for release v24.02.2 2024-03-11 14:30:48 +00:00
Dan Brown
425baf9d6e Merge branch 'development' into release 2024-03-10 18:46:05 +00:00
Dan Brown
0d3de40459 Updated translator attribution before release v24.02.1 2024-03-10 18:45:32 +00:00
Dan Brown
3619f79ca6 Updated translations with latest Crowdin changes (#4877) 2024-03-10 18:36:12 +00:00
Dan Brown
c9d9ad10f2 Merge branch 'totp-patch' into development 2024-03-10 18:32:02 +00:00
Dan Brown
d5a689366c MFA: Copied autocomplete changes from totp to backup codes
Also added tests to cover.
Related to #4849
2024-03-10 18:31:01 +00:00
Dan Brown
bc24a1360f TOTP: Added one-time-code autofill
During review of #4849
Tested on Firefox & Chromium desktop.
2024-03-10 18:24:42 +00:00
Dan Brown
77f125208e Page nav: Fixed nbsp being represented as nothing
Now represented in page nav using a normal space to avoid complete
removal of space.
Added test to cover.
For #4836
2024-03-09 15:52:09 +00:00
Dan Brown
b7d4bd5bce Breadcrumbs: Set book/shelf lists to use name ordering
Previously in database order (id) which is not predictable
nor parsable for users.
For #4876
2024-03-09 15:24:44 +00:00
Dan Brown
5a5f0b8de9 Page Display: Fixed highlighting for elements in nested details
For #4878
2024-03-09 15:07:51 +00:00
Dan Brown
825c369ad9 Updated version and assets for release v24.02 2024-02-28 13:35:36 +00:00
Dan Brown
10bab70438 Merge branch 'development' into release 2024-02-28 13:35:23 +00:00
Dan Brown
8e01345f14 Entity popular queriy: Loaded parents for selector breadcrumbs 2024-02-28 13:20:24 +00:00
Dan Brown
f5f96f84e7 404: Fixed entity list issue with entity with non-visible parent
Adds our mixed entity list loader to popular queries for more efficient
loading.
2024-02-28 13:08:06 +00:00
Dan Brown
2009d4d6a8 Translations: Updated translator attribution, added serbian to locales 2024-02-28 12:29:09 +00:00
Dan Brown
4ccfde6d02 Updated translations with latest Crowdin changes (#4803) 2024-02-28 12:19:36 +00:00
Dan Brown
c4279c9697 Merge branch 'v23-12' into development
Updated composer deps again to take lock file to current
2024-02-28 12:11:39 +00:00
Dan Brown
350e0b281b Updated version and assets for release v23.12.3 2024-02-26 12:05:02 +00:00
Dan Brown
08805ea3c8 Merge branch 'v23-12' into release 2024-02-26 12:04:25 +00:00
Dan Brown
48ea0bc291 Deps: Updated composer packages 2024-02-26 11:17:36 +00:00
Dan Brown
a75d5b8bc1 Sessions: Prevent image urls being part of session URL history
To prevent them being considered for redirects.
Includes test to cover.
For #4863
2024-02-22 11:23:59 +00:00
Dan Brown
055bbf17de Theme System: Added AUTH_PRE_REGISTER logical event
Included tests to cover.
Manually tested on standard and social (GitHub) auth.
For #4833
2024-02-21 15:30:29 +00:00
Dan Brown
be3423a16e Deps: Updated npm & composer deps
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.
(Copied this message from december deps update).
2024-02-20 18:21:59 +00:00
Dan Brown
bbb41e8b5c Breadcrumbs: Fixed bad dropdown menu placement at small sizes
For #4824
2024-02-20 18:03:32 +00:00
Dan Brown
c290d01adb WYSIWYG: Improved a range of text direction/alignment scenarios
- Removes 'span' from being a valid part of alignment formats so it's
  not used to align contents, since it's going to mostly be an inline
  format, wheras you'd really want alignment on the parent block.
- Adds direction cleaning to all direction change events, to remove
  direction styles and child direction controls which may complicate
  matters and cause direction changes not to show.
- Makes text direction controls work with table cell range selections,
  which TinyMCE does not consider by default, via manual handling.

For #4843
2024-02-20 14:15:22 +00:00
Dan Brown
16327cf40c Cover images: Updated description wording to better detail size
To make it clearer that the advised size may not be fixed.
For #4748
2024-02-19 20:26:04 +00:00
Dan Brown
999d41a7f5 WYSIWYG: Updated code handling to respect direction
Specifically supports "dir" attribute being on top level "pre" element,
and handles application/switching of this within the editor.

For #4809
2024-02-18 17:55:56 +00:00
Dan Brown
9ff9b9c805 Merge pull request #4850 from BookStackApp/table_improvements
Range of WYSIWYG Editor Table Handling Improvements
2024-02-17 16:40:27 +00:00
Dan Brown
8f1d8cef9e Tables: Added dynamic table header toggle
Shows in table context toolbar when in the first row.
2024-02-17 16:28:13 +00:00
Dan Brown
8688ad99b6 Tables: Added menu items to clear formatting and sizes 2024-02-16 14:38:30 +00:00
Dan Brown
ed0718d3f7 Tables: Added fix to ensure proper clear formatting on cell selections 2024-02-15 16:29:37 +00:00
Mattic
c53c9f6866 Turned off autocomplete for TOTP codes
Small QOL change to turn off autocomplete when entering TOTP codes since they're one time use only.
2024-02-15 09:22:35 -06:00
Dan Brown
3fdee6a93b Tables: Updated selection style to avoid scroll overflow
Fixes #4844
2024-02-15 14:40:27 +00:00
Dan Brown
cafea1c02d Updated tinymce from 6.7.2 to 6.8.3 2024-02-15 14:13:08 +00:00
Dan Brown
32e20e5059 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2024-02-14 10:36:36 +00:00
Dan Brown
c66b8ad842 RTL: Fixed pagination not responding to RTL layout
For #4808
2024-02-14 10:36:00 +00:00
Dan Brown
c9a5c29abf Merge pull request #4794 from BookStackApp/en_tweaks
Text: Tweaks to EN text for consistency/readability
2024-02-13 14:13:29 +00:00
Dan Brown
12daa1c2b9 Header: Fixed mobile menu falling out of header
Changed button to be within-DOM rather than absolute positioned.
Also improves RTL handling by showing menu on the right side.

Fixes #4841
2024-02-13 14:00:34 +00:00
Dan Brown
ff8daad22b Merge pull request #4827 from BookStackApp/query_revamp
Update of entity loading to be more efficient and avoid global addSelects
2024-02-11 15:56:32 +00:00
Dan Brown
1ea2ac864a Queries: Update API to align data with previous versions
Ensures fields returned match API docs and previous versions of
BookStack where we were accidentally returning more fields than
expected.
Updates tests to cover many of these.
Also updated clockwork to ignore image requests for less noisy
debugging.
Also updated chapter page query to not be loading all page data, via new
query in PageQueries.
2024-02-11 15:42:37 +00:00
Dan Brown
ed9c013f6e Queries: Addressed failing test cases from recent changes 2024-02-08 17:18:03 +00:00
Dan Brown
ed21a6d798 Queries: Updated old use-specific entity query classes
- Updated name to align, and differentate from new 'XQueries' clases.
- Removed old sketchy base class with app resolving workarounds, to a
  proper injection-based approach.
- Also fixed wrong translation text used in PageQueries.
2024-02-08 16:39:59 +00:00
Dan Brown
b77ab6f3af Queries: Moved out or removed some class-level items
Also ran auto-removal of unused imports across app folder.
2024-02-07 22:41:45 +00:00
Dan Brown
546cfb0dcc Queries: Extracted static page,chapter,shelf queries to classes 2024-02-07 21:58:27 +00:00
Dan Brown
483410749b Queries: Updated all app book static query uses 2024-02-07 16:37:36 +00:00
Dan Brown
c95f4ca40f Queries: Migrated revision repo queries to new class 2024-02-07 15:09:16 +00:00
Dan Brown
222c665018 Queries: Extracted PageRepo queries to own class
Started new class for PageRevisions too as part of these changes
2024-02-05 17:35:49 +00:00
Dan Brown
8e78b4c43e Queries: Extracted chapter repo queries to class
Updated query classes to align to interface for common aligned
operations.
Extracted repeated string-identifier-based finding from page/chapter
repos to shared higher-level entity queries.
2024-02-05 15:59:20 +00:00
Dan Brown
05ac0fcd1d Merge pull request #4828 from shashinma/development
Update PWA manifest orientation from 'portrait' to 'any'
2024-02-05 11:54:32 +00:00
Mikhail Shashin
9fa68fd8ab Update PWA manifest orientation to any
Changed the orientation settings in PwaManifestBuilder.php from 'portrait' to 'any'. This allows the PWA to adjust to any screen orientation, enhancing user flexibility.
2024-02-05 04:28:22 +03:00
Dan Brown
3886aedf54 Queries: Migrated bookshelf repo queries to new class 2024-02-04 19:32:19 +00:00
Dan Brown
1559b0acd1 Queries: Migrated BookRepo queries to new query class
Also moved to a non-static approach, and added a high-level class to
allow easy access to all other entity queries, for use in mixed-entity
scenarios and easier/simpler injection.
2024-02-04 17:35:16 +00:00
Dan Brown
a70ed81908 DB: Started update of entity loading to avoid global selects
Removes page/chpater addSelect global query, to load book slug, and
instead extracts base queries to be managed in new static class, while
updating specific entitiy relation loading to use our more efficient
MixedEntityListLoader where appropriate.

Related to #4823
2024-02-04 14:39:36 +00:00
Dan Brown
2460e7c56e Plonker Remediation: Removed dd line left in from debugging 2024-02-01 12:57:26 +00:00
Dan Brown
779f09bff6 Merge branch 'chapter-templates' into development 2024-02-01 12:55:38 +00:00
Dan Brown
43a72fb9a5 Default chapter templates: Added tests, extracted repo logic
- Updated existing book tests to be generic to all default templates,
  and updated with chapter testing.
- Extracted repeated logic in the Book/Chapter repos to be shared in the
  BaseRepo.

Review of #4750
2024-02-01 12:51:47 +00:00
Dan Brown
4137cf9c8f Default chapter templates: Updated api docs and tests
Also applied minor tweaks to some wording and logic.

During review of #4750
2024-02-01 12:22:16 +00:00
Dan Brown
16af833124 Merge pull request #4815 from BookStackApp/comment_wysiwyg
Comment WYSIWYG Inputs
2024-01-31 16:57:36 +00:00
Dan Brown
47f082c085 Comments: Added HTML filter test, fixed placeholder in dark mode 2024-01-31 16:47:58 +00:00
Dan Brown
fee9045dac Comments: Removed remaining uses of redundant 'text' field
Opened #4821 to remove the DB field in a few releases time.
2024-01-31 16:35:58 +00:00
Dan Brown
06901b878f Comments: Added HTML filter on load, tinymce elem filtering
- Added filter on load to help prevent potentially dangerous comment
  HTML in DB at load time (if it gets passed input filtering, or is
  existing).
- Added TinyMCE valid_elements for input wysiwygs, to gracefully degrade
  content at point of user-view, rather than surprising the user by
  stripping content, which TinyMCE would show, post-save.
2024-01-31 16:20:22 +00:00
Dan Brown
e9a19d5878 Comments: Added wysiwyg link selector, updated tests, removed command
- Updated existing tests with recent back-end changes, mainly to use
  HTML data.
- Removed old comment regen command that's no longer required.
2024-01-31 14:22:04 +00:00
Dan Brown
adf0baebb9 Comments: Added back-end HTML support, fixed editor focus
Also fixed handling of editors when moved in DOM, to properly remove
then re-init before & after move to avoid issues.
2024-01-30 15:16:58 +00:00
Dan Brown
5c92b72fdd Comments: Added input wysiwyg for creating/updating comments
Not supporting old content, existing HTML or updating yet.
2024-01-30 14:27:09 +00:00
Dan Brown
24e6dc4b37 WYSIWYG: Altered how custom head added to editors
Updated to parse and add as DOM nodes instead of innerHTML to avoid
triggering an update of all head content, which would throw warnings in
chromium in regard to setting the base URI.

For #4814
2024-01-30 11:38:47 +00:00
Sascha
4a8f70240f added template to chapter API controller 2024-01-29 19:59:03 +01:00
Sascha
64c783c6f8 extraded template form to own file and changed translations 2024-01-29 19:55:39 +01:00
Sascha
2a849894be Update entities.php
changed text of `pages_delete_warning_template` to include chapters
2024-01-29 19:37:59 +01:00
Dan Brown
415663a9bc Merge pull request #4804 from BookStackApp/oidc_pkce
Add OIDC PKCE functionality
2024-01-27 18:11:19 +00:00
Dan Brown
1dc094ffaf OIDC: Added testing of PKCE flow
Also compared full flow to RFC spec during this process
2024-01-27 16:41:15 +00:00
Dan Brown
3e9e196cda OIDC: Added PKCE functionality
Related to #4734.
Uses core logic from League AbstractProvider.
2024-01-25 14:24:46 +00:00
Dan Brown
5903823eed Merge pull request #4796 from BookStackApp/v23-12
Merge in v23.12.2 changes
2024-01-24 10:38:14 +00:00
Dan Brown
9441e32c69 Updated version and assets for release v23.12.2 2024-01-24 10:37:20 +00:00
Dan Brown
530fc37067 Merge branch 'v23-12' into release 2024-01-24 10:36:52 +00:00
Dan Brown
8fb9d9d4c2 Dependancies: Updated PHP deps via composer 2024-01-24 10:27:09 +00:00
Dan Brown
eff7aa0f73 Updated translator attribution before v23.12.2 release 2024-01-24 10:25:24 +00:00
Dan Brown
14ecb19b05 Merged l10n_development into v23-12
Squash merge
Closes #4779
2024-01-24 10:23:09 +00:00
Sascha
0fc02a2532 fixed error from phpcs 2024-01-23 22:37:15 +01:00
Sascha
8c6b116472 Update TrashCan.php
remove duplicate call of $page->forceDelete();
2024-01-23 21:37:00 +01:00
Dan Brown
69c8ff5c2d Entity selector: Fixed initial load overwriting initial search
This changes how initial searches can be handled via config rather than
specific action so they can be considered in how the initial data load
is done, to prevent the default empty state loading and overwriting the
search data if it lands later (which was commonly likely).

For #4778
2024-01-23 15:42:13 +00:00
Dan Brown
788327fffb Attachment List: Fixed broken ctrl-click functionality
Fixes #4782
2024-01-23 15:01:07 +00:00
Dan Brown
655ae5ecae Text: Tweaks to EN text for consistency/readability
As suggested by Tim in discord chat.
2024-01-23 12:31:44 +00:00
Dan Brown
d5a91d0d35 Merge pull request #4758 from BookStackApp/range_request_support
Range request support
2024-01-17 11:10:38 +00:00
Dan Brown
369e499dce Updated version and assets for release v23.12.1 2024-01-16 12:16:06 +00:00
Dan Brown
655815de6d Merge branch 'development' into release 2024-01-16 12:15:50 +00:00
Dan Brown
a4fd825fe2 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2024-01-16 12:14:44 +00:00
Dan Brown
496b4264d9 Updated translator attribution 2024-01-16 12:14:25 +00:00
Dan Brown
57284bb869 Updated translations with latest Crowdin changes (#4747) 2024-01-16 12:10:22 +00:00
Dan Brown
adf1806fea Chapters API: Added missing book_slug field
Was removed during previous changes, but reflected in response examples.
This adds into all standard single chapter responses.
For #4765
2024-01-16 12:06:13 +00:00
Dan Brown
2dc454d206 Uploads: Explicitly disabled s3 streaming in config
This was the default option anyway, just adding here for
better visibility of this being set.
Can't enable without issues as the app will attempt to seek which does
not work for these streams. Also have not tested on non-s3, s3-like
systems.
2024-01-15 13:36:04 +00:00
Dan Brown
c1552fb799 Attachments: Drag and drop video support
Supports dragging and dropping video attahchments to embed them in the
editor as HTML video tags.
2024-01-15 11:57:20 +00:00
Dan Brown
91d8d6eaaa Range requests: Added test cases to cover functionality
Fixed some found issues in the process.
2024-01-14 15:50:00 +00:00
Dan Brown
afbbcafd44 Readme: Updates sponsor list 2024-01-10 14:33:49 +00:00
Dan Brown
d94762549a Range requests: Added basic HTTP range support 2024-01-07 20:34:03 +00:00
Dan Brown
b4d9029dc3 Range requests: Extracted stream output handling to new class 2024-01-07 14:03:13 +00:00
Sascha
70bfebcd7c Added Default Templates for Chapters 2024-01-01 21:58:49 +01:00
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
Luke T. Shumaker
c76d12d1de Oidc: Properly query the UserInfo Endpoint
BooksStack's OIDC Client requests the 'profile' and 'email' scope values
in order to have access to the 'name', 'email', and other claims.  It
looks for these claims in the ID Token that is returned along with the
Access Token.

However, the OIDC-core specification section 5.4 [1] only requires that
the Provider include those claims in the ID Token *if* an Access Token is
not also issued.  If an Access Token is issued, the Provider can leave out
those claims from the ID Token, and the Client is supposed to obtain them
by submitting the Access Token to the UserInfo Endpoint.

So I suppose it's just good luck that the OIDC Providers that BookStack
has been tested with just so happen to also stick those claims in the ID
Token even though they don't have to.  But others (in particular:
https://login.infomaniak.com) don't do so, and require fetching the
UserInfo Endpoint.)

A workaround is currently possible by having the user write a theme with a
ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE hook that fetches the UserInfo
Endpoint.  This workaround isn't great, for a few reasons:
 1. Asking the user to implement core parts of the OIDC protocol is silly.
 2. The user either needs to re-fetch the .well-known/openid-configuration
    file to discover the endpoint (adding yet another round-trip to each
    login) or hard-code the endpoint, which is fragile.
 3. The hook doesn't receive the HTTP client configuration.

So, have BookStack's OidcService fetch the UserInfo Endpoint and inject
those claims into the ID Token, if a UserInfo Endpoint is defined.
Two points about this:
 - Injecting them into the ID Token's claims is the most obvious approach
   given the current code structure; though I'm not sure it is the best
   approach, perhaps it should instead fetch the user info in
   processAuthorizationResponse() and pass that as an argument to
   processAccessTokenCallback() which would then need a bit of
   restructuring.  But this made sense because it's also how the
   ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE hook works.
 - OIDC *requires* that a UserInfo Endpoint exists, so why bother with
   that "if a UserInfo Endpoint is defined" bit?  Simply out of an
   abundance of caution that there's an existing BookStack user that is
   relying on it not fetching the UserInfo Endpoint in order to work with
   a non-compliant OIDC Provider.

[1]: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
2023-12-15 14:11:48 -07: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
1044 changed files with 22031 additions and 11703 deletions

View File

@@ -215,10 +215,11 @@ LDAP_SERVER=false
LDAP_BASE_DN=false
LDAP_DN=false
LDAP_PASS=false
LDAP_USER_FILTER=false
LDAP_USER_FILTER="(&(uid={user}))"
LDAP_VERSION=false
LDAP_START_TLS=false
LDAP_TLS_INSECURE=false
LDAP_TLS_CA_CERT=false
LDAP_ID_ATTRIBUTE=uid
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
@@ -267,12 +268,14 @@ OIDC_ISSUER_DISCOVER=false
OIDC_PUBLIC_KEY=null
OIDC_AUTH_ENDPOINT=null
OIDC_TOKEN_ENDPOINT=null
OIDC_USERINFO_ENDPOINT=null
OIDC_ADDITIONAL_SCOPES=null
OIDC_DUMP_USER_DETAILS=false
OIDC_USER_TO_GROUPS=false
OIDC_GROUPS_CLAIM=groups
OIDC_REMOVE_FROM_GROUPS=false
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
@@ -323,6 +326,14 @@ FILE_UPLOAD_SIZE_LIMIT=50
# Can be 'a4' or 'letter'.
EXPORT_PAGE_SIZE=a4
# Export PDF Command
# Set a command which can be used to convert a HTML file into a PDF file.
# When false this will not be used.
# String values represent the command to be called for conversion.
# Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
EXPORT_PDF_COMMAND=false
# Set path to wkhtmltopdf binary for PDF generation.
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
# When false, BookStack will attempt to find a wkhtmltopdf in the application

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
@@ -324,7 +324,7 @@ Robin Flikkema (RobinFlikkema) :: Dutch
Michal Gurcik (mgurcik) :: Slovak
Pooyan Arab (pooyanarab) :: Persian
Ochi Darma Putra (troke12) :: Indonesian
H.-H. Peng (Hsins) :: Chinese Traditional
Hsin-Hsiang Peng (Hsins) :: Chinese Traditional
Mosi Wang (mosiwang) :: Chinese Traditional
骆言 (LawssssCat) :: Chinese Simplified
Stickers Gaming Shøw (StickerSGSHOW) :: French
@@ -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,58 @@ 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 Kobližek (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
Kasper Alsøe (zeonos) :: Danish
sultani :: Persian
renge :: Korean
Tim (thegatesdev) :: Dutch; German Informal; Romanian; French; Catalan; Czech; Danish; German; Finnish; Hungarian; Italian; Japanese; Korean; Polish; Russian; Ukrainian; Chinese Simplified; Chinese Traditional; Portuguese, Brazilian; Persian; Spanish, Argentina; Croatian; Norwegian Nynorsk; Estonian; Uzbek; Norwegian Bokmal
Irdi (irdiOL) :: Albanian
KateBarber :: Welsh
Twister (theuncles75) :: Hebrew
algernon19 :: Hungarian
Ivan Krstic (ikrstic) :: Serbian (Cyrillic)
Show :: Russian
xBahamut :: Portuguese, Brazilian
Pavle Knežević (pavleknezzevic) :: Serbian (Cyrillic)
Vanja Cvelbar (b100w11) :: Slovenian
simonpct :: French
Honza Nagy (honza.nagy) :: Czech
asd20752 :: Norwegian Bokmal
Jan Picka (polipones) :: Czech
diogoalex991 :: Portuguese
Ehsan Sadeghi (ehsansadeghi) :: Persian
ka_picit :: Danish
cracrayol :: French
CapuaSC :: Dutch
Guardian75 :: German Informal
mr-kanister :: German
Michele Bastianelli (makoblaster) :: Italian
jespernissen :: Danish
Andrey (avmaksimov) :: Russian
Gonzalo Loyola (AlFcl) :: Spanish, Argentina; Spanish
grobert63 :: French
wusst. (Supporti) :: German
MaximMaximS :: Czech
damian-klima :: Slovak
crow_ :: Latvian
JocelynDelalande :: French
Jan (JW-CH) :: German Informal
Timo B (lommes) :: German Informal

View File

@@ -18,7 +18,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.1
php-version: 8.3
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
@@ -27,10 +27,10 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-8.1
key: ${{ runner.os }}-composer-8.3
restore-keys: ${{ runner.os }}-composer-
- name: Install composer dependencies

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['8.0', '8.1', '8.2']
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v1
@@ -32,7 +32,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['8.0', '8.1', '8.2']
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v1
@@ -32,7 +32,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}

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

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015-2023, Dan Brown and the BookStack Project contributors.
Copyright (c) 2015-2024, Dan Brown and the BookStack Project contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

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

@@ -19,20 +19,25 @@ class MfaTotpController extends Controller
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret';
public function __construct(
protected TotpService $totp
) {
}
/**
* Show a view that generates and displays a TOTP QR code.
*/
public function generate(TotpService $totp)
public function generate()
{
if (session()->has(static::SETUP_SECRET_SESSION_KEY)) {
$totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY));
} else {
$totpSecret = $totp->generateSecret();
$totpSecret = $this->totp->generateSecret();
session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
}
$qrCodeUrl = $totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
$svg = $totp->generateQrCodeSvg($qrCodeUrl);
$qrCodeUrl = $this->totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
$svg = $this->totp->generateQrCodeSvg($qrCodeUrl);
$this->setPageTitle(trans('auth.mfa_gen_totp_title'));
@@ -56,7 +61,7 @@ class MfaTotpController extends Controller
'code' => [
'required',
'max:12', 'min:4',
new TotpValidationRule($totpSecret),
new TotpValidationRule($totpSecret, $this->totp),
],
]);
@@ -87,7 +92,7 @@ class MfaTotpController extends Controller
'code' => [
'required',
'max:12', 'min:4',
new TotpValidationRule($totpSecret),
new TotpValidationRule($totpSecret, $this->totp),
],
]);

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,24 +15,13 @@ use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
protected SocialAuthService $socialAuthService;
protected RegistrationService $registrationService;
protected LoginService $loginService;
/**
* Create a new controller instance.
*/
public function __construct(
SocialAuthService $socialAuthService,
RegistrationService $registrationService,
LoginService $loginService
protected SocialDriverManager $socialDriverManager,
protected RegistrationService $registrationService,
protected LoginService $loginService
) {
$this->middleware('guest');
$this->middleware('guard:standard');
$this->socialAuthService = $socialAuthService;
$this->registrationService = $registrationService;
$this->loginService = $loginService;
}
/**
@@ -43,7 +32,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,
@@ -87,6 +76,8 @@ class RegisterController extends Controller
'name' => ['required', 'min:2', 'max:100'],
'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => ['required', Password::default()],
// Basic honey for bots that must not be filled in
'username' => ['prohibited'],
]);
}
}

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

@@ -209,6 +209,12 @@ class LdapService
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
}
// Configure any user-provided CA cert files for LDAP.
// This option works globally and must be set before a connection is created.
if ($this->config['tls_ca_cert']) {
$this->configureTlsCaCerts($this->config['tls_ca_cert']);
}
$ldapHost = $this->parseServerString($this->config['server']);
$ldapConnection = $this->ldap->connect($ldapHost);
@@ -223,7 +229,14 @@ class LdapService
// Start and verify TLS if it's enabled
if ($this->config['start_tls']) {
$started = $this->ldap->startTls($ldapConnection);
try {
$started = $this->ldap->startTls($ldapConnection);
} catch (\Exception $exception) {
$error = $exception->getMessage() . ' :: ' . ldap_error($ldapConnection);
ldap_get_option($ldapConnection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $detail);
Log::info("LDAP STARTTLS failure: {$error} {$detail}");
throw new LdapException('Could not start TLS connection. Further details in the application log.');
}
if (!$started) {
throw new LdapException('Could not start TLS connection');
}
@@ -234,6 +247,33 @@ class LdapService
return $this->ldapConnection;
}
/**
* Configure TLS CA certs globally for ldap use.
* This will detect if the given path is a directory or file, and set the relevant
* LDAP TLS options appropriately otherwise throw an exception if no file/folder found.
*
* Note: When using a folder, certificates are expected to be correctly named by hash
* which can be done via the c_rehash utility.
*
* @throws LdapException
*/
protected function configureTlsCaCerts(string $caCertPath): void
{
$errMessage = "Provided path [{$caCertPath}] for LDAP TLS CA certs could not be resolved to an existing location";
$path = realpath($caCertPath);
if ($path === false) {
throw new LdapException($errMessage);
}
if (is_dir($path)) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTDIR, $path);
} else if (is_file($path)) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTFILE, $path);
} else {
throw new LdapException($errMessage);
}
}
/**
* Parse an LDAP server string and return the host suitable for a connection.
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
@@ -249,13 +289,18 @@ class LdapService
/**
* Build a filter string by injecting common variables.
* Both "${var}" and "{var}" style placeholders are supported.
* Dollar based are old format but supported for compatibility.
*/
protected function buildFilter(string $filterString, array $attrs): string
{
$newAttrs = [];
foreach ($attrs as $key => $attrText) {
$newKey = '${' . $key . '}';
$newAttrs[$newKey] = $this->ldap->escape($attrText);
$escapedText = $this->ldap->escape($attrText);
$oldVarKey = '${' . $key . '}';
$newVarKey = '{' . $key . '}';
$newAttrs[$oldVarKey] = $escapedText;
$newAttrs[$newVarKey] = $escapedText;
}
return strtr($filterString, $newAttrs);

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

@@ -2,36 +2,26 @@
namespace BookStack\Access\Mfa;
use Illuminate\Contracts\Validation\Rule;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class TotpValidationRule implements Rule
class TotpValidationRule implements ValidationRule
{
protected $secret;
protected $totpService;
/**
* Create a new rule instance.
* Takes the TOTP secret that must be system provided, not user provided.
*/
public function __construct(string $secret)
{
$this->secret = $secret;
$this->totpService = app()->make(TotpService::class);
public function __construct(
protected string $secret,
protected TotpService $totpService,
) {
}
/**
* Determine if the validation rule passes.
*/
public function passes($attribute, $value)
public function validate(string $attribute, mixed $value, Closure $fail): void
{
return $this->totpService->verifyCode($value, $this->secret);
}
/**
* Get the validation error message.
*/
public function message()
{
return trans('validation.totp');
$passes = $this->totpService->verifyCode($value, $this->secret);
if (!$passes) {
$fail(trans('validation.totp'));
}
}
}

View File

@@ -2,58 +2,8 @@
namespace BookStack\Access\Oidc;
class OidcIdToken
class OidcIdToken extends OidcJwtWithClaims implements ProvidesClaims
{
protected array $header;
protected array $payload;
protected string $signature;
protected string $issuer;
protected array $tokenParts = [];
/**
* @var array[]|string[]
*/
protected array $keys;
public function __construct(string $token, string $issuer, array $keys)
{
$this->keys = $keys;
$this->issuer = $issuer;
$this->parse($token);
}
/**
* Parse the token content into its components.
*/
protected function parse(string $token): void
{
$this->tokenParts = explode('.', $token);
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
}
/**
* Parse a Base64-JSON encoded token part.
* Returns the data as a key-value array or empty array upon error.
*/
protected function parseEncodedTokenPart(string $part): array
{
$json = $this->base64UrlDecode($part) ?: '{}';
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Base64URL decode. Needs some character conversions to be compatible
* with PHP's default base64 handling.
*/
protected function base64UrlDecode(string $encoded): string
{
return base64_decode(strtr($encoded, '-_', '+/'));
}
/**
* Validate all possible parts of the id token.
*
@@ -61,91 +11,12 @@ class OidcIdToken
*/
public function validate(string $clientId): bool
{
$this->validateTokenStructure();
$this->validateTokenSignature();
parent::validateCommonTokenDetails($clientId);
$this->validateTokenClaims($clientId);
return true;
}
/**
* Fetch a specific claim from this token.
* Returns null if it is null or does not exist.
*
* @return mixed|null
*/
public function getClaim(string $claim)
{
return $this->payload[$claim] ?? null;
}
/**
* Get all returned claims within the token.
*/
public function getAllClaims(): array
{
return $this->payload;
}
/**
* Replace the existing claim data of this token with that provided.
*/
public function replaceClaims(array $claims): void
{
$this->payload = $claims;
}
/**
* Validate the structure of the given token and ensure we have the required pieces.
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenStructure(): void
{
foreach (['header', 'payload'] as $prop) {
if (empty($this->$prop) || !is_array($this->$prop)) {
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
}
}
if (empty($this->signature) || !is_string($this->signature)) {
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
}
}
/**
* Validate the signature of the given token and ensure it validates against the provided key.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenSignature(): void
{
if ($this->header['alg'] !== 'RS256') {
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
}
$parsedKeys = array_map(function ($key) {
try {
return new OidcJwtSigningKey($key);
} catch (OidcInvalidKeyException $e) {
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
}
}, $this->keys);
$parsedKeys = array_filter($parsedKeys);
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
/** @var OidcJwtSigningKey $parsedKey */
foreach ($parsedKeys as $parsedKey) {
if ($parsedKey->verify($contentToSign, $this->signature)) {
return;
}
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
}
/**
* Validate the claims of the token.
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
@@ -156,27 +27,18 @@ class OidcIdToken
{
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
// MUST exactly match the value of the iss (issuer) Claim.
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
}
// Already done in parent.
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
// if the ID Token does not list the Client as a valid audience, or if it contains additional
// audiences not trusted by the Client.
if (empty($this->payload['aud'])) {
throw new OidcInvalidTokenException('Missing token audience value');
}
// Partially done in parent.
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
if (count($aud) !== 1) {
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
}
if ($aud[0] !== $clientId) {
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
}
// 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
// NOTE: Addressed by enforcing a count of 1 above.

View File

@@ -0,0 +1,174 @@
<?php
namespace BookStack\Access\Oidc;
class OidcJwtWithClaims implements ProvidesClaims
{
protected array $header;
protected array $payload;
protected string $signature;
protected string $issuer;
protected array $tokenParts = [];
/**
* @var array[]|string[]
*/
protected array $keys;
public function __construct(string $token, string $issuer, array $keys)
{
$this->keys = $keys;
$this->issuer = $issuer;
$this->parse($token);
}
/**
* Parse the token content into its components.
*/
protected function parse(string $token): void
{
$this->tokenParts = explode('.', $token);
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
}
/**
* Parse a Base64-JSON encoded token part.
* Returns the data as a key-value array or empty array upon error.
*/
protected function parseEncodedTokenPart(string $part): array
{
$json = $this->base64UrlDecode($part) ?: '{}';
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Base64URL decode. Needs some character conversions to be compatible
* with PHP's default base64 handling.
*/
protected function base64UrlDecode(string $encoded): string
{
return base64_decode(strtr($encoded, '-_', '+/'));
}
/**
* Validate common parts of OIDC JWT tokens.
*
* @throws OidcInvalidTokenException
*/
public function validateCommonTokenDetails(string $clientId): bool
{
$this->validateTokenStructure();
$this->validateTokenSignature();
$this->validateCommonClaims($clientId);
return true;
}
/**
* Fetch a specific claim from this token.
* Returns null if it is null or does not exist.
*/
public function getClaim(string $claim): mixed
{
return $this->payload[$claim] ?? null;
}
/**
* Get all returned claims within the token.
*/
public function getAllClaims(): array
{
return $this->payload;
}
/**
* Replace the existing claim data of this token with that provided.
*/
public function replaceClaims(array $claims): void
{
$this->payload = $claims;
}
/**
* Validate the structure of the given token and ensure we have the required pieces.
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenStructure(): void
{
foreach (['header', 'payload'] as $prop) {
if (empty($this->$prop) || !is_array($this->$prop)) {
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
}
}
if (empty($this->signature) || !is_string($this->signature)) {
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
}
}
/**
* Validate the signature of the given token and ensure it validates against the provided key.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenSignature(): void
{
if ($this->header['alg'] !== 'RS256') {
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
}
$parsedKeys = array_map(function ($key) {
try {
return new OidcJwtSigningKey($key);
} catch (OidcInvalidKeyException $e) {
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
}
}, $this->keys);
$parsedKeys = array_filter($parsedKeys);
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
/** @var OidcJwtSigningKey $parsedKey */
foreach ($parsedKeys as $parsedKey) {
if ($parsedKey->verify($contentToSign, $this->signature)) {
return;
}
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
}
/**
* Validate common claims for OIDC JWT tokens.
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation
* and https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
*
* @throws OidcInvalidTokenException
*/
protected function validateCommonClaims(string $clientId): void
{
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
// MUST exactly match the value of the iss (issuer) Claim.
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
}
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
// if the ID Token does not list the Client as a valid audience.
if (empty($this->payload['aud'])) {
throw new OidcInvalidTokenException('Missing token audience value');
}
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
if (!in_array($clientId, $aud, true)) {
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
}
}
}

View File

@@ -83,15 +83,9 @@ class OidcOAuthProvider extends AbstractProvider
/**
* Checks a provider response for errors.
*
* @param ResponseInterface $response
* @param array|string $data Parsed response data
*
* @throws IdentityProviderException
*
* @return void
*/
protected function checkResponse(ResponseInterface $response, $data)
protected function checkResponse(ResponseInterface $response, $data): void
{
if ($response->getStatusCode() >= 400 || isset($data['error'])) {
throw new IdentityProviderException(
@@ -105,13 +99,8 @@ class OidcOAuthProvider extends AbstractProvider
/**
* Generates a resource owner object from a successful resource owner
* details request.
*
* @param array $response
* @param AccessToken $token
*
* @return ResourceOwnerInterface
*/
protected function createResourceOwner(array $response, AccessToken $token)
protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface
{
return new GenericResourceOwner($response, '');
}
@@ -121,14 +110,18 @@ class OidcOAuthProvider extends AbstractProvider
*
* The grant that was used to fetch the response can be used to provide
* additional context.
*
* @param array $response
* @param AbstractGrant $grant
*
* @return OidcAccessToken
*/
protected function createAccessToken(array $response, AbstractGrant $grant)
protected function createAccessToken(array $response, AbstractGrant $grant): OidcAccessToken
{
return new OidcAccessToken($response);
}
/**
* Get the method used for PKCE code verifier hashing, which is passed
* in the "code_challenge_method" parameter in the authorization request.
*/
protected function getPkceMethod(): string
{
return static::PKCE_METHOD_S256;
}
}

View File

@@ -18,9 +18,10 @@ class OidcProviderSettings
public string $issuer;
public string $clientId;
public string $clientSecret;
public ?string $redirectUri;
public ?string $authorizationEndpoint;
public ?string $tokenEndpoint;
public ?string $endSessionEndpoint;
public ?string $userinfoEndpoint;
/**
* @var string[]|array[]
@@ -36,7 +37,7 @@ class OidcProviderSettings
/**
* Apply an array of settings to populate setting properties within this class.
*/
protected function applySettingsFromArray(array $settingsArray)
protected function applySettingsFromArray(array $settingsArray): void
{
foreach ($settingsArray as $key => $value) {
if (property_exists($this, $key)) {
@@ -50,9 +51,9 @@ class OidcProviderSettings
*
* @throws InvalidArgumentException
*/
protected function validateInitial()
protected function validateInitial(): void
{
$required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
$required = ['clientId', 'clientSecret', 'issuer'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
@@ -72,12 +73,20 @@ class OidcProviderSettings
public function validate(): void
{
$this->validateInitial();
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
}
}
$endpointProperties = ['tokenEndpoint', 'authorizationEndpoint', 'userinfoEndpoint'];
foreach ($endpointProperties as $prop) {
if (is_string($this->$prop) && !str_starts_with($this->$prop, 'https://')) {
throw new InvalidArgumentException("Endpoint value for \"{$prop}\" must start with https://");
}
}
}
/**
@@ -85,7 +94,7 @@ class OidcProviderSettings
*
* @throws OidcIssuerDiscoveryException
*/
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes): void
{
try {
$cacheKey = 'oidc-discovery::' . $this->issuer;
@@ -127,11 +136,19 @@ class OidcProviderSettings
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
}
if (!empty($result['userinfo_endpoint'])) {
$discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint'];
}
if (!empty($result['jwks_uri'])) {
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
$discoveredSettings['keys'] = $this->filterKeys($keys);
}
if (!empty($result['end_session_endpoint'])) {
$discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
}
return $discoveredSettings;
}
@@ -170,9 +187,9 @@ class OidcProviderSettings
/**
* Get the settings needed by an OAuth provider, as a key=>value array.
*/
public function arrayForProvider(): array
public function arrayForOAuthProvider(): array
{
$settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
$settingKeys = ['clientId', 'clientSecret', 'authorizationEndpoint', 'tokenEndpoint', 'userinfoEndpoint'];
$settings = [];
foreach ($settingKeys as $setting) {
$settings[$setting] = $this->$setting;

View File

@@ -12,7 +12,6 @@ use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
@@ -33,6 +32,8 @@ class OidcService
/**
* Initiate an authorization flow.
* Provides back an authorize redirect URL, in addition to other
* details which may be required for the auth flow.
*
* @throws OidcException
*
@@ -42,8 +43,12 @@ class OidcService
{
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
$url = $provider->getAuthorizationUrl();
session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
return [
'url' => $provider->getAuthorizationUrl(),
'url' => $url,
'state' => $provider->getState(),
];
}
@@ -63,6 +68,10 @@ class OidcService
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
// Set PKCE code flashed at login
$pkceCode = session()->pull('oidc_pkce_code', '');
$provider->setPkceCode($pkceCode);
// Try to exchange authorization code for access token
$accessToken = $provider->getAccessToken('authorization_code', [
'code' => $authorizationCode,
@@ -81,9 +90,10 @@ class OidcService
'issuer' => $config['issuer'],
'clientId' => $config['client_id'],
'clientSecret' => $config['client_secret'],
'redirectUri' => url('/oidc/callback'),
'authorizationEndpoint' => $config['authorization_endpoint'],
'tokenEndpoint' => $config['token_endpoint'],
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
'userinfoEndpoint' => $config['userinfo_endpoint'],
]);
// Use keys if configured
@@ -100,6 +110,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;
@@ -110,7 +128,10 @@ class OidcService
*/
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
$provider = new OidcOAuthProvider([
...$settings->arrayForOAuthProvider(),
'redirectUri' => url('/oidc/callback'),
], [
'httpClient' => $this->http->buildClient(5),
'optionProvider' => new HttpBasicAuthOptionProvider(),
]);
@@ -137,69 +158,6 @@ class OidcService
return array_filter($scopeArr);
}
/**
* Calculate the display name.
*/
protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
{
$displayNameAttrString = $this->config()['display_name_claims'] ?? '';
$displayNameAttrs = explode('|', $displayNameAttrString);
$displayName = [];
foreach ($displayNameAttrs as $dnAttr) {
$dnComponent = $token->getClaim($dnAttr) ?? '';
if ($dnComponent !== '') {
$displayName[] = $dnComponent;
}
}
if (count($displayName) == 0) {
$displayName[] = $defaultValue;
}
return implode(' ', $displayName);
}
/**
* Extract the assigned groups from the id token.
*
* @return string[]
*/
protected function getUserGroups(OidcIdToken $token): array
{
$groupsAttr = $this->config()['groups_claim'];
if (empty($groupsAttr)) {
return [];
}
$groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
if (!is_array($groupsList)) {
return [];
}
return array_values(array_filter($groupsList, function ($val) {
return is_string($val);
}));
}
/**
* Extract the details of a user from an ID token.
*
* @return array{name: string, email: string, external_id: string, groups: string[]}
*/
protected function getUserDetails(OidcIdToken $token): array
{
$idClaim = $this->config()['external_id_claim'];
$id = $token->getClaim($idClaim);
return [
'external_id' => $id,
'email' => $token->getClaim('email'),
'name' => $this->getUserDisplayName($token, $id),
'groups' => $this->getUserGroups($token),
];
}
/**
* Processes a received access token for a user. Login the user when
* they exist, optionally registering them automatically.
@@ -217,6 +175,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(),
@@ -234,34 +194,35 @@ class OidcService
try {
$idToken->validate($settings->clientId);
} catch (OidcInvalidTokenException $exception) {
throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
throw new OidcException("ID token validation failed with error: {$exception->getMessage()}");
}
$userDetails = $this->getUserDetails($idToken);
$isLoggedIn = auth()->check();
if (empty($userDetails['email'])) {
$userDetails = $this->getUserDetailsFromToken($idToken, $accessToken, $settings);
if (empty($userDetails->email)) {
throw new OidcException(trans('errors.oidc_no_email_address'));
}
if (empty($userDetails->name)) {
$userDetails->name = $userDetails->externalId;
}
$isLoggedIn = auth()->check();
if ($isLoggedIn) {
throw new OidcException(trans('errors.oidc_already_logged_in'));
}
try {
$user = $this->registrationService->findOrRegister(
$userDetails['name'],
$userDetails['email'],
$userDetails['external_id']
$userDetails->name,
$userDetails->email,
$userDetails->externalId
);
} catch (UserRegistrationException $exception) {
throw new OidcException($exception->getMessage());
}
if ($this->shouldSyncGroups()) {
$groups = $userDetails['groups'];
$detachExisting = $this->config()['remove_from_groups'];
$this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
$this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);
}
$this->loginService->login($user, 'oidc');
@@ -269,6 +230,45 @@ class OidcService
return $user;
}
/**
* @throws OidcException
*/
protected function getUserDetailsFromToken(OidcIdToken $idToken, OidcAccessToken $accessToken, OidcProviderSettings $settings): OidcUserDetails
{
$userDetails = new OidcUserDetails();
$userDetails->populate(
$idToken,
$this->config()['external_id_claim'],
$this->config()['display_name_claims'] ?? '',
$this->config()['groups_claim'] ?? ''
);
if (!$userDetails->isFullyPopulated($this->shouldSyncGroups()) && !empty($settings->userinfoEndpoint)) {
$provider = $this->getProvider($settings);
$request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken());
$response = new OidcUserinfoResponse(
$provider->getResponse($request),
$settings->issuer,
$settings->keys,
);
try {
$response->validate($idToken->getClaim('sub'), $settings->clientId);
} catch (OidcInvalidTokenException $exception) {
throw new OidcException("Userinfo endpoint response validation failed with error: {$exception->getMessage()}");
}
$userDetails->populate(
$response,
$this->config()['external_id_claim'],
$this->config()['display_name_claims'] ?? '',
$this->config()['groups_claim'] ?? ''
);
}
return $userDetails;
}
/**
* Get the OIDC config from the application.
*/
@@ -284,4 +284,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

@@ -0,0 +1,75 @@
<?php
namespace BookStack\Access\Oidc;
use Illuminate\Support\Arr;
class OidcUserDetails
{
public function __construct(
public ?string $externalId = null,
public ?string $email = null,
public ?string $name = null,
public ?array $groups = null,
) {
}
/**
* Check if the user details are fully populated for our usage.
*/
public function isFullyPopulated(bool $groupSyncActive): bool
{
$hasEmpty = empty($this->externalId)
|| empty($this->email)
|| empty($this->name)
|| ($groupSyncActive && empty($this->groups));
return !$hasEmpty;
}
/**
* Populate user details from the given claim data.
*/
public function populate(
ProvidesClaims $claims,
string $idClaim,
string $displayNameClaims,
string $groupsClaim,
): void {
$this->externalId = $claims->getClaim($idClaim) ?? $this->externalId;
$this->email = $claims->getClaim('email') ?? $this->email;
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
}
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $token): string
{
$displayNameClaimParts = explode('|', $displayNameClaims);
$displayName = [];
foreach ($displayNameClaimParts as $claim) {
$component = $token->getClaim(trim($claim)) ?? '';
if ($component !== '') {
$displayName[] = $component;
}
}
return implode(' ', $displayName);
}
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): array
{
if (empty($groupsClaim)) {
return [];
}
$groupsList = Arr::get($token->getAllClaims(), $groupsClaim);
if (!is_array($groupsList)) {
return [];
}
return array_values(array_filter($groupsList, function ($val) {
return is_string($val);
}));
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace BookStack\Access\Oidc;
use Psr\Http\Message\ResponseInterface;
class OidcUserinfoResponse implements ProvidesClaims
{
protected array $claims = [];
protected ?OidcJwtWithClaims $jwt = null;
public function __construct(ResponseInterface $response, string $issuer, array $keys)
{
$contentType = $response->getHeader('Content-Type')[0];
if ($contentType === 'application/json') {
$this->claims = json_decode($response->getBody()->getContents(), true);
}
if ($contentType === 'application/jwt') {
$this->jwt = new OidcJwtWithClaims($response->getBody()->getContents(), $issuer, $keys);
$this->claims = $this->jwt->getAllClaims();
}
}
/**
* @throws OidcInvalidTokenException
*/
public function validate(string $idTokenSub, string $clientId): bool
{
if (!is_null($this->jwt)) {
$this->jwt->validateCommonTokenDetails($clientId);
}
$sub = $this->getClaim('sub');
// Spec: v1.0 5.3.2: The sub (subject) Claim MUST always be returned in the UserInfo Response.
if (!is_string($sub) || empty($sub)) {
throw new OidcInvalidTokenException("No valid subject value found in userinfo data");
}
// Spec: v1.0 5.3.2: The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token;
// if they do not match, the UserInfo Response values MUST NOT be used.
if ($idTokenSub !== $sub) {
throw new OidcInvalidTokenException("Subject value provided in the userinfo endpoint does not match the provided ID token value");
}
// Spec v1.0 5.3.4 Defines the following:
// Verify that the OP that responded was the intended OP through a TLS server certificate check, per RFC 6125 [RFC6125].
// This is effectively done as part of the HTTP request we're making through CURLOPT_SSL_VERIFYHOST on the request.
// If the Client has provided a userinfo_encrypted_response_alg parameter during Registration, decrypt the UserInfo Response using the keys specified during Registration.
// We don't currently support JWT encryption for OIDC
// If the response was signed, the Client SHOULD validate the signature according to JWS [JWS].
// This is done as part of the validateCommonClaims above.
return true;
}
public function getClaim(string $claim): mixed
{
return $this->claims[$claim] ?? null;
}
public function getAllClaims(): array
{
return $this->claims;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace BookStack\Access\Oidc;
interface ProvidesClaims
{
/**
* Fetch a specific claim.
* Returns null if it is null or does not exist.
*/
public function getClaim(string $claim): mixed;
/**
* Get all contained claims.
*/
public function getAllClaims(): array;
}

View File

@@ -14,20 +14,14 @@ use Illuminate\Support\Str;
class RegistrationService
{
protected $userRepo;
protected $emailConfirmationService;
/**
* RegistrationService constructor.
*/
public function __construct(UserRepo $userRepo, EmailConfirmationService $emailConfirmationService)
{
$this->userRepo = $userRepo;
$this->emailConfirmationService = $emailConfirmationService;
public function __construct(
protected UserRepo $userRepo,
protected EmailConfirmationService $emailConfirmationService,
) {
}
/**
* Check whether or not registrations are allowed in the app settings.
* Check if registrations are allowed in the app settings.
*
* @throws UserRegistrationException
*/
@@ -84,6 +78,7 @@ class RegistrationService
public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User
{
$userEmail = $userData['email'];
$authSystem = $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver();
// Email restriction
$this->ensureEmailDomainAllowed($userEmail);
@@ -94,6 +89,12 @@ class RegistrationService
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
}
/** @var ?bool $shouldRegister */
$shouldRegister = Theme::dispatch(ThemeEvents::AUTH_PRE_REGISTER, $authSystem, $userData);
if ($shouldRegister === false) {
throw new UserRegistrationException(trans('errors.auth_pre_register_theme_prevention'), '/login');
}
// Create the user
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
$newUser->attachDefaultRole();
@@ -104,7 +105,7 @@ class RegistrationService
}
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver(), $newUser);
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $authSystem, $newUser);
// Start email confirmation flow if required
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
@@ -138,7 +139,7 @@ class RegistrationService
}
$restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
$userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, '@'), 1);
$userEmailDomain = mb_substr(mb_strrchr($userEmail, '@'), 1);
if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
$redirect = $this->registrationAllowed() ? '/register' : '/login';

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

@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
@@ -14,11 +15,10 @@ use Illuminate\Database\Eloquent\Relations\Relation;
class ActivityQueries
{
protected PermissionApplicator $permissions;
public function __construct(PermissionApplicator $permissions)
{
$this->permissions = $permissions;
public function __construct(
protected PermissionApplicator $permissions,
protected MixedEntityListLoader $listLoader,
) {
}
/**
@@ -27,13 +27,15 @@ class ActivityQueries
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
->orderBy('created_at', 'desc')
->with(['user', 'entity'])
->with(['user'])
->skip($count * $page)
->take($count)
->get();
$this->listLoader->loadIntoRelations($activityList->all(), 'loggable', false);
return $this->filterSimilar($activityList);
}
@@ -57,14 +59,14 @@ class ActivityQueries
$query->where(function (Builder $query) use ($queryIds) {
foreach ($queryIds as $morphClass => $idArr) {
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
$innerQuery->where('entity_type', '=', $morphClass)
->whereIn('entity_id', $idArr);
$innerQuery->where('loggable_type', '=', $morphClass)
->whereIn('loggable_id', $idArr);
});
}
});
$activity = $query->orderBy('created_at', 'desc')
->with(['entity' => function (Relation $query) {
->with(['loggable' => function (Relation $query) {
$query->withTrashed();
}, 'user.avatar'])
->skip($count * ($page - 1))
@@ -80,7 +82,7 @@ class ActivityQueries
public function userActivity(User $user, int $count = 20, int $page = 0): array
{
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)

View File

@@ -5,7 +5,7 @@ namespace BookStack\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Activity as ActivityService;
use League\CommonMark\CommonMarkConverter;
use BookStack\Util\HtmlDescriptionFilter;
class CommentRepo
{
@@ -20,13 +20,12 @@ class CommentRepo
/**
* Create a new comment on an entity.
*/
public function create(Entity $entity, string $text, ?int $parent_id): Comment
public function create(Entity $entity, string $html, ?int $parent_id): Comment
{
$userId = user()->id;
$comment = new Comment();
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
$comment->html = HtmlDescriptionFilter::filterFromString($html);
$comment->created_by = $userId;
$comment->updated_by = $userId;
$comment->local_id = $this->getNextLocalId($entity);
@@ -42,11 +41,10 @@ class CommentRepo
/**
* Update an existing comment.
*/
public function update(Comment $comment, string $text): Comment
public function update(Comment $comment, string $html): Comment
{
$comment->updated_by = user()->id;
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
$comment->html = HtmlDescriptionFilter::filterFromString($html);
$comment->save();
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
@@ -64,20 +62,6 @@ class CommentRepo
ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
}
/**
* Convert the given comment Markdown to HTML.
*/
public function commentToHtml(string $commentText): string
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'max_nesting_level' => 10,
'allow_unsafe_links' => false,
]);
return $converter->convert($commentText);
}
/**
* Get the next local ID relative to the linked entity.
*/

View File

@@ -0,0 +1,28 @@
<?php
namespace BookStack\Activity\Controllers;
use BookStack\Activity\Models\Activity;
use BookStack\Http\ApiController;
class AuditLogApiController extends ApiController
{
/**
* Get a listing of audit log events in the system.
* The loggable relation fields currently only relates to core
* content types (page, book, bookshelf, chapter) but this may be
* used more in the future across other types.
* Requires permission to manage both users and system settings.
*/
public function list()
{
$this->checkPermission('settings-manage');
$this->checkPermission('users-manage');
$query = Activity::query()->with(['user']);
return $this->apiListingResponse($query, [
'id', 'type', 'detail', 'user_id', 'loggable_id', 'loggable_type', 'ip', 'created_at',
]);
}
}

View File

@@ -32,7 +32,7 @@ class AuditLogController extends Controller
$query = Activity::query()
->with([
'entity' => fn ($query) => $query->withTrashed(),
'loggable' => fn ($query) => $query->withTrashed(),
'user',
])
->orderBy($listOptions->getSort(), $listOptions->getOrder());

View File

@@ -3,7 +3,7 @@
namespace BookStack\Activity\Controllers;
use BookStack\Activity\CommentRepo;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -11,7 +11,8 @@ use Illuminate\Validation\ValidationException;
class CommentController extends Controller
{
public function __construct(
protected CommentRepo $commentRepo
protected CommentRepo $commentRepo,
protected PageQueries $pageQueries,
) {
}
@@ -22,12 +23,12 @@ class CommentController extends Controller
*/
public function savePageComment(Request $request, int $pageId)
{
$this->validate($request, [
'text' => ['required', 'string'],
$input = $this->validate($request, [
'html' => ['required', 'string'],
'parent_id' => ['nullable', 'integer'],
]);
$page = Page::visible()->find($pageId);
$page = $this->pageQueries->findVisibleById($pageId);
if ($page === null) {
return response('Not found', 404);
}
@@ -39,7 +40,7 @@ class CommentController extends Controller
// Create a new comment.
$this->checkPermission('comment-create-all');
$comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null);
return view('comments.comment-branch', [
'readOnly' => false,
@@ -57,17 +58,20 @@ class CommentController extends Controller
*/
public function update(Request $request, int $commentId)
{
$this->validate($request, [
'text' => ['required', 'string'],
$input = $this->validate($request, [
'html' => ['required', 'string'],
]);
$comment = $this->commentRepo->getById($commentId);
$this->checkOwnablePermission('page-view', $comment->entity);
$this->checkOwnablePermission('comment-update', $comment);
$comment = $this->commentRepo->update($comment, $request->get('text'));
$comment = $this->commentRepo->update($comment, $input['html']);
return view('comments.comment', ['comment' => $comment, 'readOnly' => false]);
return view('comments.comment', [
'comment' => $comment,
'readOnly' => false,
]);
}
/**

View File

@@ -2,10 +2,7 @@
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\Queries\QueryTopFavourites;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
@@ -20,11 +17,11 @@ class FavouriteController extends Controller
/**
* Show a listing of all favourite items for the current user.
*/
public function index(Request $request)
public function index(Request $request, QueryTopFavourites $topFavourites)
{
$viewCount = 20;
$page = intval($request->get('page', 1));
$favourites = (new TopFavourites())->run($viewCount + 1, (($page - 1) * $viewCount));
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
@@ -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,31 +9,30 @@ 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;
/**
* @property string $type
* @property User $user
* @property Entity $entity
* @property Entity $loggable
* @property string $detail
* @property string $entity_type
* @property int $entity_id
* @property string $loggable_type
* @property int $loggable_id
* @property int $user_id
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Activity extends Model
{
/**
* Get the entity for this activity.
* Get the loggable model related to this activity.
* Currently only used for entities (previously entity_[id/type] columns).
* Could be used for others but will need an audit of uses where assumed
* to be entities.
*/
public function entity(): MorphTo
public function loggable(): MorphTo
{
if ($this->entity_type === '') {
$this->entity_type = null;
}
return $this->morphTo('entity');
return $this->morphTo('loggable');
}
/**
@@ -46,8 +45,8 @@ class Activity extends Model
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
return $this->hasMany(JointPermission::class, 'entity_id', 'loggable_id')
->whereColumn('activities.loggable_type', '=', 'joint_permissions.entity_type');
}
/**
@@ -73,6 +72,6 @@ class Activity extends Model
*/
public function isSimilarTo(self $activityB): bool
{
return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
return [$this->type, $this->loggable_type, $this->loggable_id] === [$activityB->type, $activityB->loggable_type, $activityB->loggable_id];
}
}

View File

@@ -4,13 +4,14 @@ namespace BookStack\Activity\Models;
use BookStack\App\Model;
use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Util\HtmlContentFilter;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property string $text
* @property string $text - Deprecated & now unused (#4821)
* @property string $html
* @property int|null $parent_id - Relates to local_id, not id
* @property int $local_id
@@ -24,7 +25,7 @@ class Comment extends Model implements Loggable
use HasFactory;
use HasCreatorAndUpdater;
protected $fillable = ['text', 'parent_id'];
protected $fillable = ['parent_id'];
protected $appends = ['created', 'updated'];
/**
@@ -73,4 +74,9 @@ class Comment extends Model implements Loggable
{
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
}
public function safeHtml(): string
{
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
}
}

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

@@ -38,7 +38,8 @@ class TagRepo
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
])
->orderBy($sort, $listOptions->getOrder());
->orderBy($sort, $listOptions->getOrder())
->whereHas('entity');
if ($nameFilter) {
$query->where('name', '=', $nameFilter);

View File

@@ -32,8 +32,8 @@ class ActivityLogger
$activity->detail = $detailToStore;
if ($detail instanceof Entity) {
$activity->entity_id = $detail->id;
$activity->entity_type = $detail->getMorphClass();
$activity->loggable_id = $detail->id;
$activity->loggable_type = $detail->getMorphClass();
}
$activity->save();
@@ -64,9 +64,9 @@ class ActivityLogger
public function removeEntity(Entity $entity): void
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
'detail' => $entity->name,
'loggable_id' => null,
'loggable_type' => null,
]);
}

View File

@@ -41,6 +41,17 @@ class CommentTree
return $this->tree;
}
public function canUpdateAny(): bool
{
foreach ($this->comments as $comment) {
if (userCan('comment-update', $comment)) {
return true;
}
}
return false;
}
/**
* @param Comment[] $comments
*/

View File

@@ -7,7 +7,6 @@ use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;

View File

@@ -3,32 +3,36 @@
namespace BookStack\App;
use BookStack\Activity\ActivityQueries;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\RecentlyViewed;
use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Queries\QueryRecentlyViewed;
use BookStack\Entities\Queries\QueryTopFavourites;
use BookStack\Entities\Tools\PageContent;
use BookStack\Http\Controller;
use BookStack\Uploads\FaviconHandler;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
class HomeController extends Controller
{
public function __construct(
protected EntityQueries $queries,
) {
}
/**
* Display the homepage.
*/
public function index(Request $request, ActivityQueries $activities)
{
public function index(
Request $request,
ActivityQueries $activities,
QueryRecentlyViewed $recentlyViewed,
QueryTopFavourites $topFavourites,
) {
$activity = $activities->latest(10);
$draftPages = [];
if ($this->isSignedIn()) {
$draftPages = Page::visible()
->where('draft', '=', true)
->where('created_by', '=', user()->id)
$draftPages = $this->queries->pages->currentUserDraftsForList()
->orderBy('updated_at', 'desc')
->with('book')
->take(6)
@@ -37,14 +41,13 @@ class HomeController extends Controller
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
$recents = $this->isSignedIn() ?
(new RecentlyViewed())->run(12 * $recentFactor, 1)
: Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
$favourites = (new TopFavourites())->run(6);
$recentlyUpdatedPages = Page::visible()->with('book')
$recentlyViewed->run(12 * $recentFactor, 1)
: $this->queries->books->visibleForList()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
$favourites = $topFavourites->run(6);
$recentlyUpdatedPages = $this->queries->pages->visibleForList()
->where('draft', false)
->orderBy('updated_at', 'desc')
->take($favourites->count() > 0 ? 5 : 10)
->select(Page::$listAttributes)
->get();
$homepageOptions = ['default', 'books', 'bookshelves', 'page'];
@@ -78,14 +81,18 @@ class HomeController extends Controller
}
if ($homepageOption === 'bookshelves') {
$shelves = app()->make(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$shelves = $this->queries->shelves->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(18);
$data = array_merge($commonData, ['shelves' => $shelves]);
return view('home.shelves', $data);
}
if ($homepageOption === 'books') {
$books = app()->make(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$books = $this->queries->books->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(18);
$data = array_merge($commonData, ['books' => $books]);
return view('home.books', $data);
@@ -95,7 +102,7 @@ class HomeController extends Controller
$homepageSetting = setting('app-homepage', '0:');
$id = intval(explode(':', $homepageSetting)[0]);
/** @var Page $customHomepage */
$customHomepage = Page::query()->where('draft', '=', false)->findOrFail($id);
$customHomepage = $this->queries->pages->start()->where('draft', '=', false)->findOrFail($id);
$pageContent = new PageContent($customHomepage);
$customHomepage->html = $pageContent->render(false);
@@ -104,48 +111,4 @@ class HomeController extends Controller
return view('home.default', $commonData);
}
/**
* Show the view for /robots.txt.
*/
public function robots()
{
$sitePublic = setting('app-public', false);
$allowRobots = config('app.allow_robots');
if ($allowRobots === null) {
$allowRobots = $sitePublic;
}
return response()
->view('misc.robots', ['allowRobots' => $allowRobots])
->header('Content-Type', 'text/plain');
}
/**
* Show the route for 404 responses.
*/
public function notFound()
{
return response()->view('errors.404', [], 404);
}
/**
* Serve the application favicon.
* Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served
* directly by the webserver in the future.
*/
public function favicon(FaviconHandler $favicons)
{
$exists = $favicons->restoreOriginalIfNotExists();
return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath());
}
/**
* Serve a PWA application manifest.
*/
public function pwaManifest(PwaManifestBuilder $manifestBuilder)
{
return response()->json($manifestBuilder->build());
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace BookStack\App;
use BookStack\Http\Controller;
use BookStack\Uploads\FaviconHandler;
class MetaController extends Controller
{
/**
* Show the view for /robots.txt.
*/
public function robots()
{
$sitePublic = setting('app-public', false);
$allowRobots = config('app.allow_robots');
if ($allowRobots === null) {
$allowRobots = $sitePublic;
}
return response()
->view('misc.robots', ['allowRobots' => $allowRobots])
->header('Content-Type', 'text/plain');
}
/**
* Show the route for 404 responses.
*/
public function notFound()
{
return response()->view('errors.404', [], 404);
}
/**
* Serve the application favicon.
* Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served
* directly by the webserver in the future.
*/
public function favicon(FaviconHandler $favicons)
{
$exists = $favicons->restoreOriginalIfNotExists();
return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath());
}
/**
* Serve a PWA application manifest.
*/
public function pwaManifest(PwaManifestBuilder $manifestBuilder)
{
return response()->json($manifestBuilder->build());
}
/**
* Show license information for the application.
*/
public function licenses()
{
$this->setPageTitle(trans('settings.licenses'));
return view('help.licenses', [
'license' => file_get_contents(base_path('LICENSE')),
'phpLibData' => file_get_contents(base_path('dev/licensing/php-library-licenses.txt')),
'jsLibData' => file_get_contents(base_path('dev/licensing/js-library-licenses.txt')),
]);
}
}

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;
@@ -25,7 +25,7 @@ class AppServiceProvider extends ServiceProvider
* Custom container bindings to register.
* @var string[]
*/
public $bindings = [
public array $bindings = [
ExceptionRenderer::class => BookStackExceptionHandlerPage::class,
];
@@ -33,20 +33,28 @@ class AppServiceProvider extends ServiceProvider
* Custom singleton bindings to register.
* @var string[]
*/
public $singletons = [
public array $singletons = [
'activity' => ActivityLogger::class,
SettingService::class => SettingService::class,
SocialAuthService::class => SocialAuthService::class,
SocialDriverManager::class => SocialDriverManager::class,
CspService::class => CspService::class,
HttpRequestService::class => HttpRequestService::class,
];
/**
* Bootstrap any application services.
*
* @return void
* Register any application services.
*/
public function boot()
public function register(): void
{
$this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null);
});
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Set root URL
$appUrl = config('app.url');
@@ -67,16 +75,4 @@ class AppServiceProvider extends ServiceProvider
'page' => Page::class,
]);
}
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null);
});
}
}

View File

@@ -18,10 +18,8 @@ class AuthServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application services.
*
* @return void
*/
public function boot()
public function boot(): void
{
// Password Configuration
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
@@ -58,10 +56,8 @@ class AuthServiceProvider extends ServiceProvider
/**
* Register the application services.
*
* @return void
*/
public function register()
public function register(): void
{
Auth::provider('external-users', function ($app, array $config) {
return new ExternalBaseUserProvider($config['model']);

View File

@@ -29,20 +29,16 @@ class EventServiceProvider extends ServiceProvider
/**
* Register any events for your application.
*
* @return void
*/
public function boot()
public function boot(): void
{
//
}
/**
* Determine if events and listeners should be automatically discovered.
*
* @return bool
*/
public function shouldDiscoverEvents()
public function shouldDiscoverEvents(): bool
{
return false;
}

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;
@@ -21,10 +24,8 @@ class RouteServiceProvider extends ServiceProvider
/**
* Define your route model bindings, pattern filters, etc.
*
* @return void
*/
public function boot()
public function boot(): void
{
$this->configureRateLimiting();
@@ -38,16 +39,21 @@ class RouteServiceProvider extends ServiceProvider
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*
* @return void
*/
protected function mapWebRoutes()
protected function mapWebRoutes(): void
{
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);
});
}
@@ -55,10 +61,8 @@ class RouteServiceProvider extends ServiceProvider
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*
* @return void
*/
protected function mapApiRoutes()
protected function mapApiRoutes(): void
{
Route::group([
'middleware' => 'api',
@@ -71,10 +75,8 @@ class RouteServiceProvider extends ServiceProvider
/**
* Configure the rate limiters for the application.
*
* @return void
*/
protected function configureRateLimiting()
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());

View File

@@ -10,10 +10,8 @@ class ThemeServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/
public function register()
public function register(): void
{
// Register the ThemeService as a singleton
$this->app->singleton(ThemeService::class, fn ($app) => new ThemeService());
@@ -21,10 +19,8 @@ class ThemeServiceProvider extends ServiceProvider
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
public function boot(): void
{
// Boot up the theme system
$themeService = $this->app->make(ThemeService::class);

View File

@@ -11,10 +11,8 @@ class TranslationServiceProvider extends BaseProvider
{
/**
* Register the service provider.
*
* @return void
*/
public function register()
public function register(): void
{
$this->registerLoader();
@@ -41,10 +39,8 @@ class TranslationServiceProvider extends BaseProvider
/**
* Register the translation line loader.
* Overrides the default register action from Laravel so a custom loader can be used.
*
* @return void
*/
protected function registerLoader()
protected function registerLoader(): void
{
$this->app->singleton('translation.loader', function ($app) {
return new FileLoader($app['files'], $app['path.lang']);

View File

@@ -12,10 +12,8 @@ class ViewTweaksServiceProvider extends ServiceProvider
{
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
public function boot(): void
{
// Set paginator to use bootstrap-style pagination
Paginator::useBootstrap();

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');
@@ -21,7 +26,7 @@ class PwaManifestBuilder
"launch_handler" => [
"client_mode" => "focus-existing"
],
"orientation" => "portrait",
"orientation" => "any",
"icons" => [
[
"src" => setting('app-icon-32') ?: url('/icon-32.png'),

View File

@@ -9,6 +9,7 @@
*/
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider;
return [
@@ -113,47 +114,20 @@ return [
],
// Application Service Providers
'providers' => [
// Laravel Framework Service Providers...
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class,
Illuminate\Cache\CacheServiceProvider::class,
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
Illuminate\Cookie\CookieServiceProvider::class,
Illuminate\Database\DatabaseServiceProvider::class,
Illuminate\Encryption\EncryptionServiceProvider::class,
Illuminate\Filesystem\FilesystemServiceProvider::class,
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class,
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
'providers' => ServiceProvider::defaultProviders()->merge([
// Third party service providers
Barryvdh\DomPDF\ServiceProvider::class,
Barryvdh\Snappy\ServiceProvider::class,
Intervention\Image\ImageServiceProvider::class,
SocialiteProviders\Manager\ServiceProvider::class,
// BookStack custom service providers
\BookStack\App\Providers\ThemeServiceProvider::class,
\BookStack\App\Providers\AppServiceProvider::class,
\BookStack\App\Providers\AuthServiceProvider::class,
\BookStack\App\Providers\EventServiceProvider::class,
\BookStack\App\Providers\RouteServiceProvider::class,
\BookStack\App\Providers\TranslationServiceProvider::class,
\BookStack\App\Providers\ValidationRuleServiceProvider::class,
\BookStack\App\Providers\ViewTweaksServiceProvider::class,
],
BookStack\App\Providers\ThemeServiceProvider::class,
BookStack\App\Providers\AppServiceProvider::class,
BookStack\App\Providers\AuthServiceProvider::class,
BookStack\App\Providers\EventServiceProvider::class,
BookStack\App\Providers\RouteServiceProvider::class,
BookStack\App\Providers\TranslationServiceProvider::class,
BookStack\App\Providers\ValidationRuleServiceProvider::class,
BookStack\App\Providers\ViewTweaksServiceProvider::class,
])->toArray(),
// Class Aliases
// This array of class aliases to be registered on application start.
@@ -161,9 +135,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

@@ -53,7 +53,8 @@ return [
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache'),
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [

View File

@@ -173,6 +173,8 @@ return [
// List of URIs that should not be collected
'except' => [
'/uploads/images/.*', // BookStack image requests
'/horizon/.*', // Laravel Horizon requests
'/telescope/.*', // Laravel Telescope requests
'/_debugbar/.*', // Laravel DebugBar requests

View File

@@ -1,23 +1,45 @@
<?php
/**
* DOMPDF configuration options.
* Export configuration options.
*
* Changes to these config files are not supported by BookStack and may break upon updates.
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
$snappyPaperSizeMap = [
'a4' => 'A4',
'letter' => 'Letter',
];
$dompdfPaperSizeMap = [
'a4' => 'a4',
'letter' => 'letter',
];
$exportPageSize = env('EXPORT_PAGE_SIZE', 'a4');
return [
'show_warnings' => false, // Throw an Exception on warnings from dompdf
// Set a command which can be used to convert a HTML file into a PDF file.
// When false this will not be used.
// String values represent the command to be called for conversion.
// Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
// Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
'pdf_command' => env('EXPORT_PDF_COMMAND', false),
'options' => [
// 2024-04: Snappy/WKHTMLtoPDF now considered deprecated in regard to BookStack support.
'snappy' => [
'pdf_binary' => env('WKHTMLTOPDF', false),
'options' => [
'print-media-type' => true,
'outline' => true,
'page-size' => $snappyPaperSizeMap[$exportPageSize] ?? 'A4',
],
],
'dompdf' => [
/**
* The location of the DOMPDF font directory.
*
@@ -101,7 +123,7 @@ return [
/**
* Whether to enable font subsetting or not.
*/
'enable_fontsubsetting' => false,
'enable_font_subsetting' => false,
/**
* The PDF rendering backend to use.
@@ -165,7 +187,7 @@ return [
*
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/
'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
'default_paper_size' => $dompdfPaperSizeMap[$exportPageSize] ?? 'a4',
/**
* The default paper orientation.
@@ -268,15 +290,6 @@ return [
*/
'font_height_ratio' => 1.1,
/**
* Enable CSS float.
*
* Allows people to disabled CSS float support
*
* @var bool
*/
'enable_css_float' => true,
/**
* Use the HTML5 Lib parser.
*
@@ -286,5 +299,4 @@ return [
*/
'enable_html5_parser' => true,
],
];

View File

@@ -58,6 +58,7 @@ return [
'endpoint' => env('STORAGE_S3_ENDPOINT', null),
'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null,
'throw' => true,
'stream_reads' => false,
],
],

View File

@@ -21,7 +21,8 @@ return [
// passwords are hashed using the Bcrypt algorithm. This will allow you
// to control the amount of time it takes to hash the given password.
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 10),
'rounds' => env('BCRYPT_ROUNDS', 12),
'verify' => true,
],
// Argon Options

View File

@@ -4,6 +4,7 @@ use Monolog\Formatter\LineFormatter;
use Monolog\Handler\ErrorLogHandler;
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Processor\PsrLogMessageProcessor;
/**
* Logging configuration options.
@@ -49,6 +50,7 @@ return [
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 14,
'replace_placeholders' => true,
],
'daily' => [
@@ -56,6 +58,7 @@ return [
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 7,
'replace_placeholders' => true,
],
'stderr' => [
@@ -65,16 +68,20 @@ return [
'with' => [
'stream' => 'php://stderr',
],
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => 'debug',
'facility' => LOG_USER,
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => 'debug',
'replace_placeholders' => true,
],
// Custom errorlog implementation that logs out a plain,
@@ -88,6 +95,7 @@ return [
'formatter_with' => [
'format' => '%message%',
],
'replace_placeholders' => true,
],
'null' => [

View File

@@ -35,6 +35,13 @@ return [
// OAuth2 endpoints.
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
'userinfo_endpoint' => env('OIDC_USERINFO_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.
@@ -45,6 +52,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

@@ -40,6 +40,12 @@ return [
],
// Job batching
'batching' => [
'database' => 'mysql',
'table' => 'job_batches',
],
// Failed queue job logging
'failed' => [
'driver' => 'database-uuids',

View File

@@ -123,7 +123,7 @@ return [
'dn' => env('LDAP_DN', false),
'pass' => env('LDAP_PASS', false),
'base_dn' => env('LDAP_BASE_DN', false),
'user_filter' => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
'user_filter' => env('LDAP_USER_FILTER', '(&(uid={user}))'),
'version' => env('LDAP_VERSION', false),
'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'),
'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
@@ -133,6 +133,7 @@ return [
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
'tls_insecure' => env('LDAP_TLS_INSECURE', false),
'tls_ca_cert' => env('LDAP_TLS_CA_CERT', false),
'start_tls' => env('LDAP_START_TLS', false),
'thumbnail_attribute' => env('LDAP_THUMBNAIL_ATTRIBUTE', null),
],

View File

@@ -85,4 +85,11 @@ return [
// do not enable this as other CSRF protection services are in place.
// Options: lax, strict, none
'same_site' => 'lax',
// Partitioned Cookies
// Setting this value to true will tie the cookie to the top-level site for
// a cross-site context. Partitioned cookies are accepted by the browser
// when flagged "secure" and the Same-Site attribute is set to "none".
'partitioned' => false,
];

View File

@@ -1,34 +0,0 @@
<?php
/**
* SnappyPDF configuration options.
*
* Changes to these config files are not supported by BookStack and may break upon updates.
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
$snappyPaperSizeMap = [
'a4' => 'A4',
'letter' => 'Letter',
];
return [
'pdf' => [
'enabled' => true,
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
'timeout' => false,
'options' => [
'outline' => true,
'page-size' => $snappyPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'A4',
],
'env' => [],
],
'image' => [
'enabled' => false,
'binary' => '/usr/local/bin/wkhtmltoimage',
'timeout' => false,
'options' => [],
'env' => [],
],
];

View File

@@ -19,7 +19,7 @@ class ClearActivityCommand extends Command
*
* @var string
*/
protected $description = 'Clear user activity from the system';
protected $description = 'Clear user (audit-log) activity from the system';
/**
* Execute the console command.

View File

@@ -2,7 +2,7 @@
namespace BookStack\Console\Commands;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Tools\PermissionsUpdater;
use Illuminate\Console\Command;
@@ -28,7 +28,7 @@ class CopyShelfPermissionsCommand extends Command
/**
* Execute the console command.
*/
public function handle(PermissionsUpdater $permissionsUpdater): int
public function handle(PermissionsUpdater $permissionsUpdater, BookshelfQueries $queries): int
{
$shelfSlug = $this->option('slug');
$cascadeAll = $this->option('all');
@@ -51,11 +51,11 @@ class CopyShelfPermissionsCommand extends Command
return 0;
}
$shelves = Bookshelf::query()->get(['id']);
$shelves = $queries->start()->get(['id']);
}
if ($shelfSlug) {
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id']);
$shelves = $queries->start()->where('slug', '=', $shelfSlug)->get(['id']);
if ($shelves->count() === 0) {
$this->info('No shelves found with the given slug.');
}

View File

@@ -1,49 +0,0 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Activity\CommentRepo;
use BookStack\Activity\Models\Comment;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RegenerateCommentContentCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:regenerate-comment-content
{--database= : The database connection to use}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Regenerate the stored HTML of all comments';
/**
* Execute the console command.
*/
public function handle(CommentRepo $commentRepo): int
{
$connection = DB::getDefaultConnection();
if ($this->option('database') !== null) {
DB::setDefaultConnection($this->option('database'));
}
Comment::query()->chunk(100, function ($comments) use ($commentRepo) {
foreach ($comments as $comment) {
$comment->html = $commentRepo->commentToHtml($comment->text);
$comment->save();
}
});
DB::setDefaultConnection($connection);
$this->comment('Comment HTML content has been regenerated');
return 0;
}
}

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

@@ -6,6 +6,7 @@ use BookStack\Api\ApiEntityListFormatter;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Http\ApiController;
@@ -14,11 +15,10 @@ 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,
protected BookQueries $queries,
) {
}
/**
@@ -26,7 +26,9 @@ class BookApiController extends ApiController
*/
public function list()
{
$books = Book::visible();
$books = $this->queries
->visibleForList()
->addSelect(['created_by', 'updated_by']);
return $this->apiListingResponse($books, [
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
@@ -47,7 +49,7 @@ class BookApiController extends ApiController
$book = $this->bookRepo->create($requestData);
return response()->json($book);
return response()->json($this->forJsonDisplay($book));
}
/**
@@ -58,7 +60,9 @@ class BookApiController extends ApiController
*/
public function read(string $id)
{
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail(intval($id));
$book = $this->forJsonDisplay($book);
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
$contents = (new BookContents($book))->getTree(true, false)->all();
$contentsApiData = (new ApiEntityListFormatter($contents))
@@ -83,13 +87,13 @@ class BookApiController extends ApiController
*/
public function update(Request $request, string $id)
{
$book = Book::visible()->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('book-update', $book);
$requestData = $this->validate($request, $this->rules()['update']);
$book = $this->bookRepo->update($book, $requestData);
return response()->json($book);
return response()->json($this->forJsonDisplay($book));
}
/**
@@ -100,7 +104,7 @@ class BookApiController extends ApiController
*/
public function delete(string $id)
{
$book = Book::visible()->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('book-delete', $book);
$this->bookRepo->destroy($book);
@@ -108,20 +112,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

@@ -6,7 +6,8 @@ use BookStack\Activity\ActivityQueries;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
@@ -24,15 +25,13 @@ 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 BookQueries $queries,
protected BookshelfQueries $shelfQueries,
protected ReferenceFetcher $referenceFetcher,
) {
}
/**
@@ -47,10 +46,12 @@ class BookController extends Controller
'updated_at' => trans('common.sort_updated_at'),
]);
$books = $this->bookRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
$recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
$popular = $this->bookRepo->getPopular(4);
$new = $this->bookRepo->getRecentlyCreated(4);
$books = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(18);
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
$popular = $this->queries->popularForList()->take(4)->get();
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
$this->shelfContext->clearShelfContext();
@@ -75,7 +76,7 @@ class BookController extends Controller
$bookshelf = null;
if ($shelfSlug !== null) {
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
}
@@ -96,15 +97,16 @@ 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;
if ($shelfSlug !== null) {
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
}
@@ -123,7 +125,7 @@ class BookController extends Controller
*/
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$book = $this->bookRepo->getBySlug($slug);
$book = $this->queries->findVisibleBySlugOrFail($slug);
$bookChildren = (new BookContents($book))->getTree(true);
$bookParentShelves = $book->shelves()->scopes('visible')->get();
@@ -141,7 +143,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),
]);
}
@@ -150,7 +152,7 @@ class BookController extends Controller
*/
public function edit(string $slug)
{
$book = $this->bookRepo->getBySlug($slug);
$book = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('book-update', $book);
$this->setPageTitle(trans('entities.books_edit_named', ['bookName' => $book->getShortName()]));
@@ -166,14 +168,15 @@ class BookController extends Controller
*/
public function update(Request $request, string $slug)
{
$book = $this->bookRepo->getBySlug($slug);
$book = $this->queries->findVisibleBySlugOrFail($slug);
$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')) {
@@ -192,7 +195,7 @@ class BookController extends Controller
*/
public function showDelete(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-delete', $book);
$this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()]));
@@ -206,7 +209,7 @@ class BookController extends Controller
*/
public function destroy(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-delete', $book);
$this->bookRepo->destroy($book);
@@ -221,7 +224,7 @@ class BookController extends Controller
*/
public function showCopy(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-view', $book);
session()->flashInput(['name' => $book->name]);
@@ -238,7 +241,7 @@ class BookController extends Controller
*/
public function copy(Request $request, Cloner $cloner, string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-view', $book);
$this->checkPermission('book-create-all');
@@ -254,7 +257,7 @@ class BookController extends Controller
*/
public function convertToShelf(HierarchyTransformer $transformer, string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$this->checkOwnablePermission('book-delete', $book);
$this->checkPermission('bookshelf-create-all');

View File

@@ -2,18 +2,17 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Http\ApiController;
use Throwable;
class BookExportApiController extends ApiController
{
protected $exportFormatter;
public function __construct(ExportFormatter $exportFormatter)
{
$this->exportFormatter = $exportFormatter;
public function __construct(
protected ExportFormatter $exportFormatter,
protected BookQueries $queries,
) {
$this->middleware('can:content-export');
}
@@ -24,7 +23,7 @@ class BookExportApiController extends ApiController
*/
public function exportPdf(int $id)
{
$book = Book::visible()->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail($id);
$pdfContent = $this->exportFormatter->bookToPdf($book);
return $this->download()->directly($pdfContent, $book->slug . '.pdf');
@@ -37,7 +36,7 @@ class BookExportApiController extends ApiController
*/
public function exportHtml(int $id)
{
$book = Book::visible()->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail($id);
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
return $this->download()->directly($htmlContent, $book->slug . '.html');
@@ -48,7 +47,7 @@ class BookExportApiController extends ApiController
*/
public function exportPlainText(int $id)
{
$book = Book::visible()->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail($id);
$textContent = $this->exportFormatter->bookToPlainText($book);
return $this->download()->directly($textContent, $book->slug . '.txt');
@@ -59,7 +58,7 @@ class BookExportApiController extends ApiController
*/
public function exportMarkdown(int $id)
{
$book = Book::visible()->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail($id);
$markdown = $this->exportFormatter->bookToMarkdown($book);
return $this->download()->directly($markdown, $book->slug . '.md');

View File

@@ -2,23 +2,17 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Http\Controller;
use Throwable;
class BookExportController extends Controller
{
protected $bookRepo;
protected $exportFormatter;
/**
* BookExportController constructor.
*/
public function __construct(BookRepo $bookRepo, ExportFormatter $exportFormatter)
{
$this->bookRepo = $bookRepo;
$this->exportFormatter = $exportFormatter;
public function __construct(
protected BookQueries $queries,
protected ExportFormatter $exportFormatter,
) {
$this->middleware('can:content-export');
}
@@ -29,7 +23,7 @@ class BookExportController extends Controller
*/
public function pdf(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$pdfContent = $this->exportFormatter->bookToPdf($book);
return $this->download()->directly($pdfContent, $bookSlug . '.pdf');
@@ -42,7 +36,7 @@ class BookExportController extends Controller
*/
public function html(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
return $this->download()->directly($htmlContent, $bookSlug . '.html');
@@ -53,7 +47,7 @@ class BookExportController extends Controller
*/
public function plainText(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$textContent = $this->exportFormatter->bookToPlainText($book);
return $this->download()->directly($textContent, $bookSlug . '.txt');
@@ -64,7 +58,7 @@ class BookExportController extends Controller
*/
public function markdown(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$textContent = $this->exportFormatter->bookToMarkdown($book);
return $this->download()->directly($textContent, $bookSlug . '.md');

View File

@@ -3,7 +3,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\BookSortMap;
use BookStack\Facades\Activity;
@@ -12,11 +12,9 @@ use Illuminate\Http\Request;
class BookSortController extends Controller
{
protected $bookRepo;
public function __construct(BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
public function __construct(
protected BookQueries $queries,
) {
}
/**
@@ -24,7 +22,7 @@ class BookSortController extends Controller
*/
public function show(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$bookChildren = (new BookContents($book))->getTree(false);
@@ -40,7 +38,7 @@ class BookSortController extends Controller
*/
public function showItem(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$bookChildren = (new BookContents($book))->getTree();
return view('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
@@ -51,7 +49,7 @@ class BookSortController extends Controller
*/
public function update(Request $request, string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-update', $book);
// Return if no map sent

View File

@@ -3,6 +3,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Http\ApiController;
use Exception;
@@ -12,11 +13,10 @@ 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,
protected BookshelfQueries $queries,
) {
}
/**
@@ -24,7 +24,9 @@ class BookshelfApiController extends ApiController
*/
public function list()
{
$shelves = Bookshelf::visible();
$shelves = $this->queries
->visibleForList()
->addSelect(['created_by', 'updated_by']);
return $this->apiListingResponse($shelves, [
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
@@ -48,7 +50,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 +58,14 @@ class BookshelfApiController extends ApiController
*/
public function read(string $id)
{
$shelf = Bookshelf::visible()->with([
'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
$shelf = $this->queries->findVisibleByIdOrFail(intval($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);
}
@@ -78,7 +82,7 @@ class BookshelfApiController extends ApiController
*/
public function update(Request $request, string $id)
{
$shelf = Bookshelf::visible()->findOrFail($id);
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('bookshelf-update', $shelf);
$requestData = $this->validate($request, $this->rules()['update']);
@@ -86,7 +90,7 @@ class BookshelfApiController extends ApiController
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
return response()->json($shelf);
return response()->json($this->forJsonDisplay($shelf));
}
/**
@@ -97,7 +101,7 @@ class BookshelfApiController extends ApiController
*/
public function delete(string $id)
{
$shelf = Bookshelf::visible()->findOrFail($id);
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->bookshelfRepo->destroy($shelf);
@@ -105,22 +109,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

@@ -4,7 +4,8 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\ActivityQueries;
use BookStack\Activity\Models\View;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException;
@@ -18,15 +19,13 @@ 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 BookshelfQueries $queries,
protected BookQueries $bookQueries,
protected ShelfContext $shelfContext,
protected ReferenceFetcher $referenceFetcher,
) {
}
/**
@@ -41,10 +40,15 @@ class BookshelfController extends Controller
'updated_at' => trans('common.sort_updated_at'),
]);
$shelves = $this->shelfRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
$recents = $this->isSignedIn() ? $this->shelfRepo->getRecentlyViewed(4) : false;
$popular = $this->shelfRepo->getPopular(4);
$new = $this->shelfRepo->getRecentlyCreated(4);
$shelves = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(18);
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
$popular = $this->queries->popularForList()->get();
$new = $this->queries->visibleForList()
->orderBy('created_at', 'desc')
->take(4)
->get();
$this->shelfContext->clearShelfContext();
$this->setPageTitle(trans('entities.shelves'));
@@ -65,7 +69,7 @@ class BookshelfController extends Controller
public function create()
{
$this->checkPermission('bookshelf-create-all');
$books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
$books = $this->bookQueries->visibleForList()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
$this->setPageTitle(trans('entities.shelves_create'));
return view('shelves.create', ['books' => $books]);
@@ -81,10 +85,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', ''));
@@ -100,7 +104,7 @@ class BookshelfController extends Controller
*/
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-view', $shelf);
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
@@ -129,7 +133,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),
]);
}
@@ -138,11 +142,14 @@ class BookshelfController extends Controller
*/
public function edit(string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-update', $shelf);
$shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
$books = Book::visible()->whereNotIn('id', $shelfBookIds)->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
$books = $this->bookQueries->visibleForList()
->whereNotIn('id', $shelfBookIds)
->orderBy('name')
->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
$this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()]));
@@ -161,13 +168,13 @@ class BookshelfController extends Controller
*/
public function update(Request $request, string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->queries->findVisibleBySlugOrFail($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')) {
@@ -187,7 +194,7 @@ class BookshelfController extends Controller
*/
public function showDelete(string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()]));
@@ -202,7 +209,7 @@ class BookshelfController extends Controller
*/
public function destroy(string $slug)
{
$shelf = $this->shelfRepo->getBySlug($slug);
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->shelfRepo->destroy($shelf);

View File

@@ -2,8 +2,9 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\ApiController;
@@ -15,23 +16,29 @@ 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'],
'default_template_id' => ['nullable', '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'],
'default_template_id' => ['nullable', 'integer'],
],
];
public function __construct(
protected ChapterRepo $chapterRepo
protected ChapterRepo $chapterRepo,
protected ChapterQueries $queries,
protected EntityQueries $entityQueries,
) {
}
@@ -40,7 +47,8 @@ class ChapterApiController extends ApiController
*/
public function list()
{
$chapters = Chapter::visible();
$chapters = $this->queries->visibleForList()
->addSelect(['created_by', 'updated_by']);
return $this->apiListingResponse($chapters, [
'id', 'book_id', 'name', 'slug', 'description', 'priority',
@@ -56,12 +64,12 @@ class ChapterApiController extends ApiController
$requestData = $this->validate($request, $this->rules['create']);
$bookId = $request->get('book_id');
$book = Book::visible()->findOrFail($bookId);
$book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId));
$this->checkOwnablePermission('chapter-create', $book);
$chapter = $this->chapterRepo->create($requestData, $book);
return response()->json($chapter->load(['tags']));
return response()->json($this->forJsonDisplay($chapter));
}
/**
@@ -69,9 +77,17 @@ 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 = $this->queries->findVisibleByIdOrFail(intval($id));
$chapter = $this->forJsonDisplay($chapter);
$chapter->load(['createdBy', 'updatedBy', 'ownedBy']);
// Note: More fields than usual here, for backwards compatibility,
// due to previously accidentally including more fields that desired.
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)
->addSelect(['created_by', 'updated_by', 'revision_count', 'editor'])
->get();
$chapter->setRelation('pages', $pages);
return response()->json($chapter);
}
@@ -84,7 +100,7 @@ class ChapterApiController extends ApiController
public function update(Request $request, string $id)
{
$requestData = $this->validate($request, $this->rules()['update']);
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('chapter-update', $chapter);
if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
@@ -93,7 +109,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 +119,7 @@ class ChapterApiController extends ApiController
$updatedChapter = $this->chapterRepo->update($chapter, $requestData);
return response()->json($updatedChapter->load(['tags']));
return response()->json($this->forJsonDisplay($updatedChapter));
}
/**
@@ -112,11 +128,24 @@ class ChapterApiController extends ApiController
*/
public function delete(string $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->chapterRepo->destroy($chapter);
return response('', 204);
}
protected function forJsonDisplay(Chapter $chapter): Chapter
{
$chapter = clone $chapter;
$chapter->unsetRelations()->refresh();
$chapter->load(['tags']);
$chapter->makeVisible('description_html');
$chapter->setAttribute('description_html', $chapter->descriptionHtml());
$chapter->setAttribute('book_slug', $chapter->book()->first()->slug);
return $chapter;
}
}

View File

@@ -5,6 +5,8 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
@@ -12,6 +14,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 +24,12 @@ 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 ChapterQueries $queries,
protected EntityQueries $entityQueries,
protected ReferenceFetcher $referenceFetcher,
) {
}
/**
@@ -35,12 +37,15 @@ class ChapterController extends Controller
*/
public function create(string $bookSlug)
{
$book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
$book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('chapter-create', $book);
$this->setPageTitle(trans('entities.chapters_create'));
return view('chapters.create', ['book' => $book, 'current' => $book]);
return view('chapters.create', [
'book' => $book,
'current' => $book,
]);
}
/**
@@ -50,14 +55,17 @@ 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'],
'default_template_id' => ['nullable', 'integer'],
]);
$book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
$book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('chapter-create', $book);
$chapter = $this->chapterRepo->create($request->all(), $book);
$chapter = $this->chapterRepo->create($validated, $book);
return redirect($chapter->getUrl());
}
@@ -67,11 +75,12 @@ class ChapterController extends Controller
*/
public function show(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
$sidebarTree = (new BookContents($chapter->book))->getTree();
$pages = $chapter->getVisiblePages();
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
$nextPreviousLocator = new NextPreviousContentLocator($chapter, $sidebarTree);
View::incrementFor($chapter);
@@ -86,7 +95,7 @@ class ChapterController extends Controller
'pages' => $pages,
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($chapter),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($chapter),
]);
}
@@ -95,7 +104,7 @@ class ChapterController extends Controller
*/
public function edit(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()]));
@@ -110,10 +119,17 @@ class ChapterController extends Controller
*/
public function update(Request $request, string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'default_template_id' => ['nullable', 'integer'],
]);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->chapterRepo->update($chapter, $request->all());
$this->chapterRepo->update($chapter, $validated);
return redirect($chapter->getUrl());
}
@@ -125,7 +141,7 @@ class ChapterController extends Controller
*/
public function showDelete(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()]));
@@ -141,7 +157,7 @@ class ChapterController extends Controller
*/
public function destroy(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->chapterRepo->destroy($chapter);
@@ -156,7 +172,7 @@ class ChapterController extends Controller
*/
public function showMove(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->setPageTitle(trans('entities.chapters_move_named', ['chapterName' => $chapter->getShortName()]));
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
@@ -170,11 +186,11 @@ 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)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
@@ -184,13 +200,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());
@@ -203,7 +219,7 @@ class ChapterController extends Controller
*/
public function showCopy(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
session()->flashInput(['name' => $chapter->name]);
@@ -222,16 +238,16 @@ class ChapterController extends Controller
*/
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
$entitySelection = $request->get('entity_selection') ?: null;
$newParentBook = $entitySelection ? $this->chapterRepo->findParentByIdentifier($entitySelection) : $chapter->getParent();
$newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent();
if (is_null($newParentBook)) {
if (!$newParentBook instanceof Book) {
$this->showErrorNotification(trans('errors.selected_book_not_found'));
return redirect()->back();
return redirect($chapter->getUrl('/copy'));
}
$this->checkOwnablePermission('chapter-create', $newParentBook);
@@ -248,7 +264,7 @@ class ChapterController extends Controller
*/
public function convertToBook(HierarchyTransformer $transformer, string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkPermission('book-create-all');

View File

@@ -2,21 +2,17 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Http\ApiController;
use Throwable;
class ChapterExportApiController extends ApiController
{
protected $exportFormatter;
/**
* ChapterExportController constructor.
*/
public function __construct(ExportFormatter $exportFormatter)
{
$this->exportFormatter = $exportFormatter;
public function __construct(
protected ExportFormatter $exportFormatter,
protected ChapterQueries $queries,
) {
$this->middleware('can:content-export');
}
@@ -27,7 +23,7 @@ class ChapterExportApiController extends ApiController
*/
public function exportPdf(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail($id);
$pdfContent = $this->exportFormatter->chapterToPdf($chapter);
return $this->download()->directly($pdfContent, $chapter->slug . '.pdf');
@@ -40,7 +36,7 @@ class ChapterExportApiController extends ApiController
*/
public function exportHtml(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail($id);
$htmlContent = $this->exportFormatter->chapterToContainedHtml($chapter);
return $this->download()->directly($htmlContent, $chapter->slug . '.html');
@@ -51,7 +47,7 @@ class ChapterExportApiController extends ApiController
*/
public function exportPlainText(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail($id);
$textContent = $this->exportFormatter->chapterToPlainText($chapter);
return $this->download()->directly($textContent, $chapter->slug . '.txt');
@@ -62,7 +58,7 @@ class ChapterExportApiController extends ApiController
*/
public function exportMarkdown(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail($id);
$markdown = $this->exportFormatter->chapterToMarkdown($chapter);
return $this->download()->directly($markdown, $chapter->slug . '.md');

View File

@@ -2,7 +2,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Exceptions\NotFoundException;
use BookStack\Http\Controller;
@@ -10,16 +10,10 @@ use Throwable;
class ChapterExportController extends Controller
{
protected $chapterRepo;
protected $exportFormatter;
/**
* ChapterExportController constructor.
*/
public function __construct(ChapterRepo $chapterRepo, ExportFormatter $exportFormatter)
{
$this->chapterRepo = $chapterRepo;
$this->exportFormatter = $exportFormatter;
public function __construct(
protected ChapterQueries $queries,
protected ExportFormatter $exportFormatter,
) {
$this->middleware('can:content-export');
}
@@ -31,7 +25,7 @@ class ChapterExportController extends Controller
*/
public function pdf(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$pdfContent = $this->exportFormatter->chapterToPdf($chapter);
return $this->download()->directly($pdfContent, $chapterSlug . '.pdf');
@@ -45,7 +39,7 @@ class ChapterExportController extends Controller
*/
public function html(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$containedHtml = $this->exportFormatter->chapterToContainedHtml($chapter);
return $this->download()->directly($containedHtml, $chapterSlug . '.html');
@@ -58,7 +52,7 @@ class ChapterExportController extends Controller
*/
public function plainText(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$chapterText = $this->exportFormatter->chapterToPlainText($chapter);
return $this->download()->directly($chapterText, $chapterSlug . '.txt');
@@ -71,7 +65,7 @@ class ChapterExportController extends Controller
*/
public function markdown(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$chapterText = $this->exportFormatter->chapterToMarkdown($chapter);
return $this->download()->directly($chapterText, $chapterSlug . '.md');

View File

@@ -2,9 +2,8 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\ApiController;
@@ -35,7 +34,9 @@ class PageApiController extends ApiController
];
public function __construct(
protected PageRepo $pageRepo
protected PageRepo $pageRepo,
protected PageQueries $queries,
protected EntityQueries $entityQueries,
) {
}
@@ -44,7 +45,8 @@ class PageApiController extends ApiController
*/
public function list()
{
$pages = Page::visible();
$pages = $this->queries->visibleForList()
->addSelect(['created_by', 'updated_by', 'revision_count', 'editor']);
return $this->apiListingResponse($pages, [
'id', 'book_id', 'chapter_id', 'name', 'slug', 'priority',
@@ -70,9 +72,9 @@ class PageApiController extends ApiController
$this->validate($request, $this->rules['create']);
if ($request->has('chapter_id')) {
$parent = Chapter::visible()->findOrFail($request->get('chapter_id'));
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
} else {
$parent = Book::visible()->findOrFail($request->get('book_id'));
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
}
$this->checkOwnablePermission('page-create', $parent);
@@ -97,7 +99,7 @@ class PageApiController extends ApiController
*/
public function read(string $id)
{
$page = $this->pageRepo->getById($id, []);
$page = $this->queries->findVisibleByIdOrFail($id);
return response()->json($page->forJsonDisplay());
}
@@ -113,14 +115,14 @@ class PageApiController extends ApiController
{
$requestData = $this->validate($request, $this->rules['update']);
$page = $this->pageRepo->getById($id, []);
$page = $this->queries->findVisibleByIdOrFail($id);
$this->checkOwnablePermission('page-update', $page);
$parent = null;
if ($request->has('chapter_id')) {
$parent = Chapter::visible()->findOrFail($request->get('chapter_id'));
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
} elseif ($request->has('book_id')) {
$parent = Book::visible()->findOrFail($request->get('book_id'));
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
}
if ($parent && !$parent->matches($page->getParent())) {
@@ -148,7 +150,7 @@ class PageApiController extends ApiController
*/
public function delete(string $id)
{
$page = $this->pageRepo->getById($id, []);
$page = $this->queries->findVisibleByIdOrFail($id);
$this->checkOwnablePermission('page-delete', $page);
$this->pageRepo->destroy($page);

View File

@@ -5,7 +5,10 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
@@ -27,6 +30,8 @@ class PageController extends Controller
{
public function __construct(
protected PageRepo $pageRepo,
protected PageQueries $queries,
protected EntityQueries $entityQueries,
protected ReferenceFetcher $referenceFetcher
) {
}
@@ -38,7 +43,12 @@ class PageController extends Controller
*/
public function create(string $bookSlug, string $chapterSlug = null)
{
$parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug);
if ($chapterSlug) {
$parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
} else {
$parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
}
$this->checkOwnablePermission('page-create', $parent);
// Redirect to draft edit screen if signed in
@@ -65,13 +75,17 @@ class PageController extends Controller
'name' => ['required', 'string', 'max:255'],
]);
$parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug);
if ($chapterSlug) {
$parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
} else {
$parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
}
$this->checkOwnablePermission('page-create', $parent);
$page = $this->pageRepo->getNewDraftPage($parent);
$this->pageRepo->publishDraft($page, [
'name' => $request->get('name'),
'html' => '',
]);
return redirect($page->getUrl('/edit'));
@@ -84,10 +98,10 @@ class PageController extends Controller
*/
public function editDraft(Request $request, string $bookSlug, int $pageId)
{
$draft = $this->pageRepo->getById($pageId);
$draft = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-create', $draft->getParent());
$editorData = new PageEditorData($draft, $this->pageRepo, $request->query('editor', ''));
$editorData = new PageEditorData($draft, $this->entityQueries, $request->query('editor', ''));
$this->setPageTitle(trans('entities.pages_edit_draft'));
return view('pages.edit', $editorData->getViewData());
@@ -104,7 +118,7 @@ class PageController extends Controller
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
]);
$draftPage = $this->pageRepo->getById($pageId);
$draftPage = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-create', $draftPage->getParent());
$page = $this->pageRepo->publishDraft($draftPage, $request->all());
@@ -121,11 +135,12 @@ class PageController extends Controller
public function show(string $bookSlug, string $pageSlug)
{
try {
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
} catch (NotFoundException $e) {
$page = $this->pageRepo->getByOldSlug($bookSlug, $pageSlug);
$revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
$page = $revision->page ?? null;
if ($page === null) {
if (is_null($page)) {
throw $e;
}
@@ -155,7 +170,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),
]);
}
@@ -166,7 +181,7 @@ class PageController extends Controller
*/
public function getPageAjax(int $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->queries->findVisibleByIdOrFail($pageId);
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
$page->makeHidden(['book']);
@@ -180,10 +195,10 @@ class PageController extends Controller
*/
public function edit(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$editorData = new PageEditorData($page, $this->pageRepo, $request->query('editor', ''));
$editorData = new PageEditorData($page, $this->entityQueries, $request->query('editor', ''));
if ($editorData->getWarnings()) {
$this->showWarningNotification(implode("\n", $editorData->getWarnings()));
}
@@ -204,7 +219,7 @@ class PageController extends Controller
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
]);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->pageRepo->update($page, $request->all());
@@ -219,7 +234,7 @@ class PageController extends Controller
*/
public function saveDraft(Request $request, int $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-update', $page);
if (!$this->isSignedIn()) {
@@ -244,7 +259,7 @@ class PageController extends Controller
*/
public function redirectFromLink(int $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->queries->findVisibleByIdOrFail($pageId);
return redirect($page->getUrl());
}
@@ -256,14 +271,18 @@ class PageController extends Controller
*/
public function showDelete(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()]));
$usedAsTemplate =
$this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
$this->entityQueries->chapters->start()->where('default_template_id', '=', $page->id)->count() > 0;
return view('pages.delete', [
'book' => $page->book,
'page' => $page,
'current' => $page,
'usedAsTemplate' => $usedAsTemplate,
]);
}
@@ -274,14 +293,18 @@ class PageController extends Controller
*/
public function showDeleteDraft(string $bookSlug, int $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-update', $page);
$this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()]));
$usedAsTemplate =
$this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
$this->entityQueries->chapters->start()->where('default_template_id', '=', $page->id)->count() > 0;
return view('pages.delete', [
'book' => $page->book,
'page' => $page,
'current' => $page,
'usedAsTemplate' => $usedAsTemplate,
]);
}
@@ -293,7 +316,7 @@ class PageController extends Controller
*/
public function destroy(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$parent = $page->getParent();
@@ -310,7 +333,7 @@ class PageController extends Controller
*/
public function destroyDraft(string $bookSlug, int $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->queries->findVisibleByIdOrFail($pageId);
$book = $page->book;
$chapter = $page->chapter;
$this->checkOwnablePermission('page-update', $page);
@@ -335,7 +358,9 @@ class PageController extends Controller
$query->scopes('visible');
};
$pages = Page::visible()->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope])
$pages = $this->queries->visibleForList()
->addSelect('updated_by')
->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope])
->orderBy('updated_at', 'desc')
->paginate(20)
->setPath(url('/pages/recently-updated'));
@@ -357,7 +382,7 @@ class PageController extends Controller
*/
public function showMove(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('page-delete', $page);
@@ -375,7 +400,7 @@ class PageController extends Controller
*/
public function move(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('page-delete', $page);
@@ -391,7 +416,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());
@@ -404,7 +429,7 @@ class PageController extends Controller
*/
public function showCopy(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-view', $page);
session()->flashInput(['name' => $page->name]);
@@ -422,16 +447,16 @@ class PageController extends Controller
*/
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-view', $page);
$entitySelection = $request->get('entity_selection') ?: null;
$newParent = $entitySelection ? $this->pageRepo->findParentByIdentifier($entitySelection) : $page->getParent();
$newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent();
if (is_null($newParent)) {
if (!$newParent instanceof Book && !$newParent instanceof Chapter) {
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect()->back();
return redirect($page->getUrl('/copy'));
}
$this->checkOwnablePermission('page-create', $newParent);

View File

@@ -2,18 +2,17 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Http\ApiController;
use Throwable;
class PageExportApiController extends ApiController
{
protected $exportFormatter;
public function __construct(ExportFormatter $exportFormatter)
{
$this->exportFormatter = $exportFormatter;
public function __construct(
protected ExportFormatter $exportFormatter,
protected PageQueries $queries,
) {
$this->middleware('can:content-export');
}
@@ -24,7 +23,7 @@ class PageExportApiController extends ApiController
*/
public function exportPdf(int $id)
{
$page = Page::visible()->findOrFail($id);
$page = $this->queries->findVisibleByIdOrFail($id);
$pdfContent = $this->exportFormatter->pageToPdf($page);
return $this->download()->directly($pdfContent, $page->slug . '.pdf');
@@ -37,7 +36,7 @@ class PageExportApiController extends ApiController
*/
public function exportHtml(int $id)
{
$page = Page::visible()->findOrFail($id);
$page = $this->queries->findVisibleByIdOrFail($id);
$htmlContent = $this->exportFormatter->pageToContainedHtml($page);
return $this->download()->directly($htmlContent, $page->slug . '.html');
@@ -48,7 +47,7 @@ class PageExportApiController extends ApiController
*/
public function exportPlainText(int $id)
{
$page = Page::visible()->findOrFail($id);
$page = $this->queries->findVisibleByIdOrFail($id);
$textContent = $this->exportFormatter->pageToPlainText($page);
return $this->download()->directly($textContent, $page->slug . '.txt');
@@ -59,7 +58,7 @@ class PageExportApiController extends ApiController
*/
public function exportMarkdown(int $id)
{
$page = Page::visible()->findOrFail($id);
$page = $this->queries->findVisibleByIdOrFail($id);
$markdown = $this->exportFormatter->pageToMarkdown($page);
return $this->download()->directly($markdown, $page->slug . '.md');

View File

@@ -2,7 +2,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Entities\Tools\PageContent;
use BookStack\Exceptions\NotFoundException;
@@ -11,16 +11,10 @@ use Throwable;
class PageExportController extends Controller
{
protected $pageRepo;
protected $exportFormatter;
/**
* PageExportController constructor.
*/
public function __construct(PageRepo $pageRepo, ExportFormatter $exportFormatter)
{
$this->pageRepo = $pageRepo;
$this->exportFormatter = $exportFormatter;
public function __construct(
protected PageQueries $queries,
protected ExportFormatter $exportFormatter,
) {
$this->middleware('can:content-export');
}
@@ -33,7 +27,7 @@ class PageExportController extends Controller
*/
public function pdf(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$page->html = (new PageContent($page))->render();
$pdfContent = $this->exportFormatter->pageToPdf($page);
@@ -48,7 +42,7 @@ class PageExportController extends Controller
*/
public function html(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$page->html = (new PageContent($page))->render();
$containedHtml = $this->exportFormatter->pageToContainedHtml($page);
@@ -62,7 +56,7 @@ class PageExportController extends Controller
*/
public function plainText(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$pageText = $this->exportFormatter->pageToPlainText($page);
return $this->download()->directly($pageText, $pageSlug . '.txt');
@@ -75,7 +69,7 @@ class PageExportController extends Controller
*/
public function markdown(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$pageText = $this->exportFormatter->pageToMarkdown($page);
return $this->download()->directly($pageText, $pageSlug . '.md');

View File

@@ -4,6 +4,7 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Models\PageRevision;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Repos\RevisionRepo;
use BookStack\Entities\Tools\PageContent;
@@ -18,6 +19,7 @@ class PageRevisionController extends Controller
{
public function __construct(
protected PageRepo $pageRepo,
protected PageQueries $pageQueries,
protected RevisionRepo $revisionRepo,
) {
}
@@ -29,7 +31,7 @@ class PageRevisionController extends Controller
*/
public function index(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([
'id' => trans('entities.pages_revisions_sort_number')
]);
@@ -60,7 +62,7 @@ class PageRevisionController extends Controller
*/
public function show(string $bookSlug, string $pageSlug, int $revisionId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
/** @var ?PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
if ($revision === null) {
@@ -89,7 +91,7 @@ class PageRevisionController extends Controller
*/
public function changes(string $bookSlug, string $pageSlug, int $revisionId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
/** @var ?PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
if ($revision === null) {
@@ -121,7 +123,7 @@ class PageRevisionController extends Controller
*/
public function restore(string $bookSlug, string $pageSlug, int $revisionId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$page = $this->pageRepo->restoreRevision($page, $revisionId);
@@ -136,7 +138,7 @@ class PageRevisionController extends Controller
*/
public function destroy(string $bookSlug, string $pageSlug, int $revId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$revision = $page->revisions()->where('id', '=', $revId)->first();
@@ -162,7 +164,7 @@ class PageRevisionController extends Controller
*/
public function destroyUserDraft(string $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
$this->revisionRepo->deleteDraftsForCurrentUser($page);
return response('', 200);

View File

@@ -2,6 +2,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\NotFoundException;
use BookStack\Http\Controller;
@@ -9,14 +10,10 @@ use Illuminate\Http\Request;
class PageTemplateController extends Controller
{
protected $pageRepo;
/**
* PageTemplateController constructor.
*/
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
public function __construct(
protected PageRepo $pageRepo,
protected PageQueries $pageQueries,
) {
}
/**
@@ -26,7 +23,19 @@ class PageTemplateController extends Controller
{
$page = $request->get('page', 1);
$search = $request->get('search', '');
$templates = $this->pageRepo->getTemplates(10, $page, $search);
$count = 10;
$query = $this->pageQueries->visibleTemplates()
->orderBy('name', 'asc')
->skip(($page - 1) * $count)
->take($count);
if ($search) {
$query->where('name', 'like', '%' . $search . '%');
}
$templates = $query->paginate($count, ['*'], 'page', $page);
$templates->withPath('/templates');
if ($search) {
$templates->appends(['search' => $search]);
@@ -44,7 +53,7 @@ class PageTemplateController extends Controller
*/
public function get(int $templateId)
{
$page = $this->pageRepo->getById($templateId);
$page = $this->pageQueries->findVisibleByIdOrFail($templateId);
if (!$page->template) {
throw new NotFoundException();

View File

@@ -116,9 +116,9 @@ class RecycleBinController extends Controller
*
* @throws \Exception
*/
public function empty()
public function empty(TrashCan $trash)
{
$deleteCount = (new TrashCan())->empty();
$deleteCount = $trash->empty();
$this->logActivity(ActivityType::RECYCLE_BIN_EMPTY);
$this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));

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.
*/
@@ -106,20 +117,11 @@ class Book extends Entity implements HasCoverImage
/**
* Get the direct child items within this book.
*/
public function getDirectChildren(): Collection
public function getDirectVisibleChildren(): Collection
{
$pages = $this->directPages()->scopes('visible')->get();
$chapters = $this->chapters()->scopes('visible')->get();
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
}
/**
* Get a visible book by its slug.
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function getBySlug(string $slug): self
{
return static::visible()->where('slug', '=', $slug)->firstOrFail();
}
}

View File

@@ -13,38 +13,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property int $priority
* @property string $book_slug
* @property Book $book
*
* @method Builder whereSlugs(string $bookSlug, string $childSlug)
*/
abstract class BookChild extends Entity
{
protected static function boot()
{
parent::boot();
// Load book slugs onto these models by default during query-time
static::addGlobalScope('book_slug', function (Builder $builder) {
$builder->addSelect(['book_slug' => function ($builder) {
$builder->select('slug')
->from('books')
->whereColumn('books.id', '=', 'book_id');
}]);
});
}
/**
* Scope a query to find items where the child has the given childSlug
* where its parent has the bookSlug.
*/
public function scopeWhereSlugs(Builder $query, string $bookSlug, string $childSlug)
{
return $query->with('book')
->whereHas('book', function (Builder $query) use ($bookSlug) {
$query->where('slug', '=', $bookSlug);
})
->where('slug', '=', $childSlug);
}
/**
* Get the book this page sits in.
*/
@@ -65,7 +36,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

@@ -2,6 +2,7 @@
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
@@ -10,16 +11,18 @@ use Illuminate\Support\Collection;
* Class Chapter.
*
* @property Collection<Page> $pages
* @property string $description
* @property ?int $default_template_id
* @property ?Page $defaultTemplate
*/
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.
@@ -47,6 +50,14 @@ class Chapter extends BookChild
return url('/' . implode('/', $parts));
}
/**
* Get the Page that is used as default template for newly created pages within this Chapter.
*/
public function defaultTemplate(): BelongsTo
{
return $this->belongsTo(Page::class, 'default_template_id');
}
/**
* Get the visible pages in this chapter.
*/
@@ -58,13 +69,4 @@ class Chapter extends BookChild
->orderBy('priority', 'asc')
->get();
}
/**
* Get a visible chapter by its book and page slugs.
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function getBySlugs(string $bookSlug, string $chapterSlug): self
{
return static::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
}
}

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.
@@ -132,7 +137,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function activity(): MorphMany
{
return $this->morphMany(Activity::class, 'entity')
return $this->morphMany(Activity::class, 'loggable')
->orderBy('created_at', 'desc');
}

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

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