Compare commits

...

206 Commits

Author SHA1 Message Date
Dan Brown
48f235ea5a Updated version and assets for release v24.05.2 2024-06-10 11:44:06 +01:00
Dan Brown
047771b9f4 Merge branch 'development' into release 2024-06-10 11:43:05 +01:00
Dan Brown
c096b20d9c Updated translator & dependency attribution before release v24.05.2 2024-06-10 11:42:37 +01:00
Dan Brown
11a7ccc37e SAML: Set static type to pass static checks
Not totally clear if underlying code can actually return null, but
playing it safe to remain as-is for now for patch release.
2024-06-10 10:31:35 +01:00
Dan Brown
d9b9e6c0b1 Updated translations with latest Crowdin changes (#5022) 2024-06-10 10:16:34 +01:00
Dan Brown
f18d42f08e Merge pull request #5036 from bradenterpstra01/development
Fixed incorrect code shortcut reference
2024-06-09 23:23:28 +01:00
Dan Brown
4986f008b9 Merge pull request #5052 from michaelortnerit/development
Update docker-compose.yml
2024-06-09 23:20:01 +01:00
Dan Brown
a8ce199e0d Pages: Fixed unused changelog on first page publish
Included test to cover.
For #5056
2024-06-09 17:18:23 +01:00
Dan Brown
c77e8730d6 Deps: Updated php packages via composer 2024-06-09 17:03:29 +01:00
Dan Brown
3406846c82 Images: Updated GIF handling to use native methods
Changes GIF image thumbnail handling to direcly load via gd instead of
going through interventions own handling (which supports frames) since
we don't need animation for our thumbnails, and since performance issues
could arise with GIFs that have large frame counts.

For #5029
2024-06-09 17:00:58 +01:00
Dan Brown
bddc6ae66b Roles: Added max validation for role external auth id field
For #5037
2024-06-08 20:33:34 +01:00
Michael Ortner
bd6e3c022f Update docker-compose.yml
Remove the version: because it is obsolete. See: https://docs.docker.com/compose/compose-file/04-version-and-name/#version-top-level-element-optional
2024-06-04 15:07:09 +02:00
bradenterpstra01
8759fff116 Update wysiwyg.blade.php
Remove the Shift for the numeric shortcut for incline code.

Ctrl+8 instead of Ctrl+Shift+8

I assume Mac is the same but I do not have a Mac to test with.
2024-05-29 18:01:48 -04:00
Dan Brown
6019d2ee14 MFA: Tweaked backup code wording
It was not clear before as it could be taken that the system would
securely store the codes.

Closes #5017
2024-05-23 11:30:53 +01:00
Dan Brown
b5375114d3 Updated version and assets for release v24.05.1 2024-05-21 11:07:36 +01:00
Dan Brown
fc13e56cea Merge branch 'development' into release 2024-05-21 11:07:10 +01:00
Dan Brown
f937bf3abb Updated translator & dependency attribution before release v24.05.1 2024-05-21 11:06:08 +01:00
Dan Brown
586e8963a8 Updated translations with latest Crowdin changes (#4994) 2024-05-21 11:04:27 +01:00
Dan Brown
bdfa76ed9a Deps: Updated php/composer packages 2024-05-20 17:28:53 +01:00
Dan Brown
d133f904d3 Auth: Changed email confirmations to use login attempt user
Negates the need for a public confirmation resend form
since we can instead just send direct to the last session login attempter.
2024-05-20 17:23:15 +01:00
Dan Brown
69af9e0dbd Routes: Added throttling to a range of auth-related endpoints
Some already throttled in some means, but this adds a simple ip-based
non-request-specific layer to many endpoints.
Related to #4993
2024-05-20 14:00:58 +01:00
Dan Brown
72c5141dec File Uploads: Added basic validation response formatting
Tested via app-level validation file limit, and then also with nginx
file post limit.
For #4996
2024-05-18 21:18:15 +01:00
Dan Brown
5651d2c43d Config: Reverted change to cache directory
Change made during Laravel 10 updates to align (Laravel made this change
much earlier in 5.x series) but it caused issues due to folder not
pre-existing and due to potentiall permission issues.
(CLI could create this during update, with non-compatible permissions
for webserver).

For #4999
2024-05-18 20:40:26 +01:00
Dan Brown
fc236f930b Dark Mode: Fixed setting labels missing dark mode handling
Fixes #5018
2024-05-18 20:37:49 +01:00
Dan Brown
570af500f4 WYSIWYG: Added justify cell range cleanup
To help override & gain control of setting text alignment in tables.

- Adds support of clearing "align" attributes in certain operations.
- Updates cell range action handling to dedupe execcommand handling.
- Adds clearing of additional alignment classes on direction control.

Closes #5011
2024-05-16 14:59:30 +01:00
Dan Brown
38913288d8 Devdocs: Fixed visual theme system lang folder reference
Made some other minor updates while there.
Fixes #4998
2024-05-16 14:15:26 +01:00
Dan Brown
c14d7d9509 Merge pull request #5008 from KiDxS/fix-notification-preferences-url-in-email
Fixed notification preferences URL in email
2024-05-16 14:11:15 +01:00
Angelo Geant Gaviola
79f5be4170 Fixed notification preferences URL in email 2024-05-14 17:04:23 +08:00
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
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
930 changed files with 15391 additions and 7770 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,6 +268,7 @@ 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
@@ -324,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

@@ -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
@@ -347,7 +347,7 @@ Taygun Yıldırım (yildirimtaygun) :: Turkish
robing29 :: German
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
Igor V Belousov (biv) :: Russian
David Bauer (davbauer) :: German
David Bauer (davbauer) :: German; German Informal
Guttorm Hveem (guttormhveem) :: Norwegian Nynorsk; Norwegian Bokmal
Minh Giang Truong (minhgiang1204) :: Vietnamese
Ioannis Ioannides (i.ioannides) :: Greek
@@ -374,7 +374,7 @@ balmag :: Hungarian
Antti-Jussi Nygård (ajnyga) :: Finnish
Eduard Ereza Martínez (Ereza) :: Catalan
Jabir Lang (amar.almrad) :: Arabic
Jaroslav Koblizek (foretix) :: Czech; French
Jaroslav Kobližek (foretix) :: Czech; French
Wiktor Adamczyk (adamczyk.wiktor) :: Polish
Abdulmajeed Alshuaibi (4Majeed) :: Arabic
NotSmartZakk :: Czech
@@ -386,3 +386,46 @@ 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; French; Romanian; 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
Erik Lundstedt (Erik.Lundstedt) :: Swedish
yngams (younessmouhid) :: Arabic
Ohadp :: Hebrew
cbridi :: Portuguese, Brazilian
nanangsb :: Indonesian
Michal Melich (michalmelich) :: Czech
David (david-prv) :: German

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', '8.3']
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', '8.3']
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

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

@@ -32,13 +32,17 @@ class ConfirmEmailController extends Controller
/**
* Shows a notice that a user's email address has not been confirmed,
* Also has the option to re-send the confirmation email.
* along with the option to re-send the confirmation email.
*/
public function showAwaiting()
{
$user = $this->loginService->getLastLoginAttemptUser();
if ($user === null) {
$this->showErrorNotification(trans('errors.login_user_not_found'));
return redirect('/login');
}
return view('auth.user-unconfirmed', ['user' => $user]);
return view('auth.register-confirm-awaiting');
}
/**
@@ -90,19 +94,24 @@ class ConfirmEmailController extends Controller
/**
* Resend the confirmation email.
*/
public function resend(Request $request)
public function resend()
{
$this->validate($request, [
'email' => ['required', 'email', 'exists:users,email'],
]);
$user = $this->userRepo->getByEmail($request->get('email'));
$user = $this->loginService->getLastLoginAttemptUser();
if ($user === null) {
$this->showErrorNotification(trans('errors.login_user_not_found'));
return redirect('/login');
}
try {
$this->emailConfirmationService->sendConfirmation($user);
} catch (ConfirmationEmailException $e) {
$this->showErrorNotification($e->getMessage());
return redirect('/login');
} catch (Exception $e) {
$this->showErrorNotification(trans('auth.email_confirm_send_error'));
return redirect('/register/confirm');
return redirect('/register/awaiting');
}
$this->showSuccessNotification(trans('auth.email_confirm_resent'));

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Sleep;
class ForgotPasswordController extends Controller
{
@@ -32,6 +33,10 @@ class ForgotPasswordController extends Controller
'email' => ['required', 'email'],
]);
// Add random pause to the response to help avoid time-base sniffing
// of valid resets via slower email send handling.
Sleep::for(random_int(1000, 3000))->milliseconds();
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.

View File

@@ -17,7 +17,7 @@ trait HandlesPartialLogins
$user = auth()->user() ?? $loginService->getLastLoginAttemptUser();
if (!$user) {
throw new NotFoundException('A user for this action could not be found');
throw new NotFoundException(trans('errors.login_user_not_found'));
}
return $user;

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

@@ -15,24 +15,13 @@ use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
protected SocialDriverManager $socialDriverManager;
protected RegistrationService $registrationService;
protected LoginService $loginService;
/**
* Create a new controller instance.
*/
public function __construct(
SocialDriverManager $socialDriverManager,
RegistrationService $registrationService,
LoginService $loginService
protected SocialDriverManager $socialDriverManager,
protected RegistrationService $registrationService,
protected LoginService $loginService
) {
$this->middleware('guest');
$this->middleware('guard:standard');
$this->socialDriverManager = $socialDriverManager;
$this->registrationService = $registrationService;
$this->loginService = $loginService;
}
/**
@@ -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

@@ -15,14 +15,11 @@ use Illuminate\Validation\Rules\Password as PasswordRule;
class ResetPasswordController extends Controller
{
protected LoginService $loginService;
public function __construct(LoginService $loginService)
{
public function __construct(
protected LoginService $loginService
) {
$this->middleware('guest');
$this->middleware('guard:standard');
$this->loginService = $loginService;
}
/**

View File

@@ -17,7 +17,7 @@ class EmailConfirmationService extends UserTokenService
*
* @throws ConfirmationEmailException
*/
public function sendConfirmation(User $user)
public function sendConfirmation(User $user): void
{
if ($user->email_confirmed) {
throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login');

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

@@ -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,10 +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[]
@@ -37,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)) {
@@ -51,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");
@@ -73,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://");
}
}
}
/**
@@ -86,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;
@@ -128,6 +136,10 @@ 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);
@@ -175,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,10 +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
@@ -119,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(),
]);
@@ -146,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.
@@ -245,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');
@@ -280,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.
*/

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

@@ -133,6 +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.
/** @var ?string $samlRedirect */
$samlRedirect = $toolkit->processSLO(true, $requestId, true, null, true);
$errors = $toolkit->getErrors();

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,7 +2,7 @@
namespace BookStack\Activity\Controllers;
use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Queries\QueryTopFavourites;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
@@ -17,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;

View File

@@ -15,26 +15,24 @@ 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');
}
/**
@@ -47,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');
}
/**
@@ -74,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

@@ -43,7 +43,7 @@ abstract class BaseActivityNotification extends MailNotification
protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine
{
return new LinkedMailMessageLine(
url('/preferences/notifications'),
url('/my-account/notifications'),
$locale->trans('notifications.footer_reason'),
$locale->trans('notifications.footer_reason_link'),
);

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

@@ -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,7 +33,7 @@ class AppServiceProvider extends ServiceProvider
* Custom singleton bindings to register.
* @var string[]
*/
public $singletons = [
public array $singletons = [
'activity' => ActivityLogger::class,
SettingService::class => SettingService::class,
SocialDriverManager::class => SocialDriverManager::class,
@@ -42,11 +42,19 @@ class AppServiceProvider extends ServiceProvider
];
/**
* 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

@@ -24,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();
@@ -41,10 +39,8 @@ 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',
@@ -65,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',
@@ -81,13 +75,15 @@ 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());
});
RateLimiter::for('public', function (Request $request) {
return Limit::perMinute(10)->by($request->ip());
});
}
}

View File

@@ -4,17 +4,14 @@ namespace BookStack\App\Providers;
use BookStack\Theming\ThemeEvents;
use BookStack\Theming\ThemeService;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class ThemeServiceProvider extends ServiceProvider
{
/**
* 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());
@@ -22,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

@@ -26,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,46 +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,
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.

View File

@@ -54,6 +54,7 @@ return [
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache'),
'lock_path' => storage_path('framework/cache'),
],
'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,7 @@ 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.

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

@@ -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;
@@ -15,7 +16,8 @@ use Illuminate\Validation\ValidationException;
class BookApiController extends ApiController
{
public function __construct(
protected BookRepo $bookRepo
protected BookRepo $bookRepo,
protected BookQueries $queries,
) {
}
@@ -24,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',
@@ -56,7 +60,7 @@ class BookApiController extends ApiController
*/
public function read(string $id)
{
$book = Book::visible()->findOrFail($id);
$book = $this->queries->findVisibleByIdOrFail(intval($id));
$book = $this->forJsonDisplay($book);
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
@@ -83,7 +87,7 @@ 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']);
@@ -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);

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;
@@ -27,7 +28,9 @@ class BookController extends Controller
public function __construct(
protected ShelfContext $shelfContext,
protected BookRepo $bookRepo,
protected ReferenceFetcher $referenceFetcher
protected BookQueries $queries,
protected BookshelfQueries $shelfQueries,
protected ReferenceFetcher $referenceFetcher,
) {
}
@@ -43,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();
@@ -71,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);
}
@@ -101,7 +106,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);
}
@@ -120,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();
@@ -147,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()]));
@@ -163,7 +168,7 @@ 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, [
@@ -190,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()]));
@@ -204,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);
@@ -219,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]);
@@ -236,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');
@@ -252,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;
@@ -13,7 +14,8 @@ use Illuminate\Validation\ValidationException;
class BookshelfApiController extends ApiController
{
public function __construct(
protected BookshelfRepo $bookshelfRepo
protected BookshelfRepo $bookshelfRepo,
protected BookshelfQueries $queries,
) {
}
@@ -22,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',
@@ -54,7 +58,7 @@ class BookshelfApiController extends ApiController
*/
public function read(string $id)
{
$shelf = Bookshelf::visible()->findOrFail($id);
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
$shelf = $this->forJsonDisplay($shelf);
$shelf->load([
'createdBy', 'updatedBy', 'ownedBy',
@@ -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']);
@@ -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);

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;
@@ -20,8 +21,10 @@ class BookshelfController extends Controller
{
public function __construct(
protected BookshelfRepo $shelfRepo,
protected BookshelfQueries $queries,
protected BookQueries $bookQueries,
protected ShelfContext $shelfContext,
protected ReferenceFetcher $referenceFetcher
protected ReferenceFetcher $referenceFetcher,
) {
}
@@ -37,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'));
@@ -61,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]);
@@ -96,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([
@@ -134,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()]));
@@ -157,7 +168,7 @@ 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'],
@@ -183,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()]));
@@ -198,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,25 +16,29 @@ class ChapterApiController extends ApiController
{
protected $rules = [
'create' => [
'book_id' => ['required', 'integer'],
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'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:1900'],
'description_html' => ['string', 'max:2000'],
'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,
) {
}
@@ -42,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',
@@ -58,7 +64,7 @@ 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);
@@ -71,15 +77,17 @@ class ChapterApiController extends ApiController
*/
public function read(string $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
$chapter = $this->forJsonDisplay($chapter);
$chapter->load([
'createdBy', 'updatedBy', 'ownedBy',
'pages' => function (HasMany $query) {
$query->scopes('visible')->get(['id', 'name', 'slug']);
}
]);
$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);
}
@@ -92,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'])) {
@@ -120,7 +128,7 @@ 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);
@@ -134,8 +142,9 @@ class ChapterApiController extends ApiController
$chapter->unsetRelations()->refresh();
$chapter->load(['tags']);
$chapter->makeVisible('description_html')
->setAttribute('description_html', $chapter->descriptionHtml());
$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;
@@ -24,7 +26,9 @@ class ChapterController extends Controller
{
public function __construct(
protected ChapterRepo $chapterRepo,
protected ReferenceFetcher $referenceFetcher
protected ChapterQueries $queries,
protected EntityQueries $entityQueries,
protected ReferenceFetcher $referenceFetcher,
) {
}
@@ -33,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,
]);
}
/**
@@ -49,12 +56,13 @@ class ChapterController extends Controller
public function store(Request $request, string $bookSlug)
{
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'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($validated, $book);
@@ -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);
@@ -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()]));
@@ -111,12 +120,13 @@ class ChapterController extends Controller
public function update(Request $request, string $bookSlug, string $chapterSlug)
{
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'default_template_id' => ['nullable', 'integer'],
]);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->chapterRepo->update($chapter, $validated);
@@ -131,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()]));
@@ -147,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);
@@ -162,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);
@@ -180,7 +190,7 @@ class ChapterController extends Controller
*/
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);
@@ -209,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]);
@@ -228,13 +238,13 @@ 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($chapter->getUrl('/copy'));
@@ -254,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

@@ -6,7 +6,9 @@ use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\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;
@@ -28,6 +30,8 @@ class PageController extends Controller
{
public function __construct(
protected PageRepo $pageRepo,
protected PageQueries $queries,
protected EntityQueries $entityQueries,
protected ReferenceFetcher $referenceFetcher
) {
}
@@ -39,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
@@ -66,7 +75,12 @@ 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);
@@ -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;
}
@@ -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,10 +271,12 @@ 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 = Book::query()->where('default_template_id', '=', $page->id)->count() > 0;
$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,
@@ -276,10 +293,12 @@ 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 = Book::query()->where('default_template_id', '=', $page->id)->count() > 0;
$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,
@@ -297,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();
@@ -314,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);
@@ -339,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'));
@@ -361,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);
@@ -379,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);
@@ -408,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]);
@@ -426,13 +447,13 @@ 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($page->getUrl('/copy'));

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

@@ -117,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.
*/

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,7 +11,8 @@ 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
{
@@ -48,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.
*/
@@ -59,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

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

@@ -32,9 +32,6 @@ class Page extends BookChild
{
use HasFactory;
public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at', 'priority'];
public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at', 'priority'];
protected $fillable = ['name', 'priority'];
public string $textField = 'text';
@@ -145,13 +142,4 @@ class Page extends BookChild
return $refreshed;
}
/**
* Get a visible page by its book and page slugs.
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function getBySlugs(string $bookSlug, string $pageSlug): self
{
return static::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail();
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Book;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Builder;
class BookQueries implements ProvidesEntityQueries
{
protected static array $listAttributes = [
'id', 'slug', 'name', 'description',
'created_at', 'updated_at', 'image_id', 'owned_by',
];
public function start(): Builder
{
return Book::query();
}
public function findVisibleById(int $id): ?Book
{
return $this->start()->scopes('visible')->find($id);
}
public function findVisibleByIdOrFail(int $id): Book
{
return $this->start()->scopes('visible')->findOrFail($id);
}
public function findVisibleBySlugOrFail(string $slug): Book
{
/** @var ?Book $book */
$book = $this->start()
->scopes('visible')
->where('slug', '=', $slug)
->first();
if ($book === null) {
throw new NotFoundException(trans('errors.book_not_found'));
}
return $book;
}
public function visibleForList(): Builder
{
return $this->start()->scopes('visible')
->select(static::$listAttributes);
}
public function visibleForListWithCover(): Builder
{
return $this->visibleForList()->with('cover');
}
public function recentlyViewedForCurrentUser(): Builder
{
return $this->visibleForList()
->scopes('withLastView')
->having('last_viewed_at', '>', 0)
->orderBy('last_viewed_at', 'desc');
}
public function popularForList(): Builder
{
return $this->visibleForList()
->scopes('withViewCount')
->having('view_count', '>', 0)
->orderBy('view_count', 'desc');
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Builder;
class BookshelfQueries implements ProvidesEntityQueries
{
protected static array $listAttributes = [
'id', 'slug', 'name', 'description',
'created_at', 'updated_at', 'image_id', 'owned_by',
];
public function start(): Builder
{
return Bookshelf::query();
}
public function findVisibleById(int $id): ?Bookshelf
{
return $this->start()->scopes('visible')->find($id);
}
public function findVisibleByIdOrFail(int $id): Bookshelf
{
$shelf = $this->findVisibleById($id);
if (is_null($shelf)) {
throw new NotFoundException(trans('errors.bookshelf_not_found'));
}
return $shelf;
}
public function findVisibleBySlugOrFail(string $slug): Bookshelf
{
/** @var ?Bookshelf $shelf */
$shelf = $this->start()
->scopes('visible')
->where('slug', '=', $slug)
->first();
if ($shelf === null) {
throw new NotFoundException(trans('errors.bookshelf_not_found'));
}
return $shelf;
}
public function visibleForList(): Builder
{
return $this->start()->scopes('visible')->select(static::$listAttributes);
}
public function visibleForListWithCover(): Builder
{
return $this->visibleForList()->with('cover');
}
public function recentlyViewedForCurrentUser(): Builder
{
return $this->visibleForList()
->scopes('withLastView')
->having('last_viewed_at', '>', 0)
->orderBy('last_viewed_at', 'desc');
}
public function popularForList(): Builder
{
return $this->visibleForList()
->scopes('withViewCount')
->having('view_count', '>', 0)
->orderBy('view_count', 'desc');
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Chapter;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Builder;
class ChapterQueries implements ProvidesEntityQueries
{
protected static array $listAttributes = [
'id', 'slug', 'name', 'description', 'priority',
'book_id', 'created_at', 'updated_at', 'owned_by',
];
public function start(): Builder
{
return Chapter::query();
}
public function findVisibleById(int $id): ?Chapter
{
return $this->start()->scopes('visible')->find($id);
}
public function findVisibleByIdOrFail(int $id): Chapter
{
return $this->start()->scopes('visible')->findOrFail($id);
}
public function findVisibleBySlugsOrFail(string $bookSlug, string $chapterSlug): Chapter
{
/** @var ?Chapter $chapter */
$chapter = $this->start()
->scopes('visible')
->with('book')
->whereHas('book', function (Builder $query) use ($bookSlug) {
$query->where('slug', '=', $bookSlug);
})
->where('slug', '=', $chapterSlug)
->first();
if (is_null($chapter)) {
throw new NotFoundException(trans('errors.chapter_not_found'));
}
return $chapter;
}
public function usingSlugs(string $bookSlug, string $chapterSlug): Builder
{
return $this->start()
->where('slug', '=', $chapterSlug)
->whereHas('book', function (Builder $query) use ($bookSlug) {
$query->where('slug', '=', $bookSlug);
});
}
public function visibleForList(): Builder
{
return $this->start()
->scopes('visible')
->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) {
$builder->select('slug')
->from('books')
->whereColumn('books.id', '=', 'chapters.book_id');
}]));
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Builder;
use InvalidArgumentException;
class EntityQueries
{
public function __construct(
public BookshelfQueries $shelves,
public BookQueries $books,
public ChapterQueries $chapters,
public PageQueries $pages,
public PageRevisionQueries $revisions,
) {
}
/**
* Find an entity via an identifier string in the format:
* {type}:{id}
* Example: (book:5).
*/
public function findVisibleByStringIdentifier(string $identifier): ?Entity
{
$explodedId = explode(':', $identifier);
$entityType = $explodedId[0];
$entityId = intval($explodedId[1]);
$queries = $this->getQueriesForType($entityType);
return $queries->findVisibleById($entityId);
}
/**
* Start a query of visible entities of the given type,
* suitable for listing display.
*/
public function visibleForList(string $entityType): Builder
{
$queries = $this->getQueriesForType($entityType);
return $queries->visibleForList();
}
protected function getQueriesForType(string $type): ProvidesEntityQueries
{
/** @var ?ProvidesEntityQueries $queries */
$queries = match ($type) {
'page' => $this->pages,
'chapter' => $this->chapters,
'book' => $this->books,
'bookshelf' => $this->shelves,
default => null,
};
if (is_null($queries)) {
throw new InvalidArgumentException("No entity query class configured for {$type}");
}
return $queries;
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\EntityProvider;
use BookStack\Permissions\PermissionApplicator;
abstract class EntityQuery
{
protected function permissionService(): PermissionApplicator
{
return app()->make(PermissionApplicator::class);
}
protected function entityProvider(): EntityProvider
{
return app()->make(EntityProvider::class);
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Builder;
class PageQueries implements ProvidesEntityQueries
{
protected static array $contentAttributes = [
'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft',
'template', 'html', 'text', 'created_at', 'updated_at', 'priority',
'created_by', 'updated_by', 'owned_by',
];
protected static array $listAttributes = [
'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft',
'template', 'text', 'created_at', 'updated_at', 'priority', 'owned_by',
];
public function start(): Builder
{
return Page::query();
}
public function findVisibleById(int $id): ?Page
{
return $this->start()->scopes('visible')->find($id);
}
public function findVisibleByIdOrFail(int $id): Page
{
$page = $this->findVisibleById($id);
if (is_null($page)) {
throw new NotFoundException(trans('errors.page_not_found'));
}
return $page;
}
public function findVisibleBySlugsOrFail(string $bookSlug, string $pageSlug): Page
{
/** @var ?Page $page */
$page = $this->start()->with('book')
->scopes('visible')
->whereHas('book', function (Builder $query) use ($bookSlug) {
$query->where('slug', '=', $bookSlug);
})
->where('slug', '=', $pageSlug)
->first();
if (is_null($page)) {
throw new NotFoundException(trans('errors.page_not_found'));
}
return $page;
}
public function usingSlugs(string $bookSlug, string $pageSlug): Builder
{
return $this->start()
->where('slug', '=', $pageSlug)
->whereHas('book', function (Builder $query) use ($bookSlug) {
$query->where('slug', '=', $bookSlug);
});
}
public function visibleForList(): Builder
{
return $this->start()
->scopes('visible')
->select($this->mergeBookSlugForSelect(static::$listAttributes));
}
public function visibleForChapterList(int $chapterId): Builder
{
return $this->visibleForList()
->where('chapter_id', '=', $chapterId)
->orderBy('draft', 'desc')
->orderBy('priority', 'asc');
}
public function visibleWithContents(): Builder
{
return $this->start()
->scopes('visible')
->select($this->mergeBookSlugForSelect(static::$contentAttributes));
}
public function currentUserDraftsForList(): Builder
{
return $this->visibleForList()
->where('draft', '=', true)
->where('created_by', '=', user()->id);
}
public function visibleTemplates(): Builder
{
return $this->visibleForList()
->where('template', '=', true);
}
protected function mergeBookSlugForSelect(array $columns): array
{
return array_merge($columns, ['book_slug' => function ($builder) {
$builder->select('slug')
->from('books')
->whereColumn('books.id', '=', 'pages.book_id');
}]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\PageRevision;
use Illuminate\Database\Eloquent\Builder;
class PageRevisionQueries
{
public function start(): Builder
{
return PageRevision::query();
}
public function findLatestVersionBySlugs(string $bookSlug, string $pageSlug): ?PageRevision
{
return PageRevision::query()
->whereHas('page', function (Builder $query) {
$query->scopes('visible');
})
->where('slug', '=', $pageSlug)
->where('type', '=', 'version')
->where('book_slug', '=', $bookSlug)
->orderBy('created_at', 'desc')
->first();
}
public function findLatestCurrentUserDraftsForPageId(int $pageId): ?PageRevision
{
/** @var ?PageRevision $revision */
$revision = $this->latestCurrentUserDraftsForPageId($pageId)->first();
return $revision;
}
public function latestCurrentUserDraftsForPageId(int $pageId): Builder
{
return $this->start()
->where('created_by', '=', user()->id)
->where('type', 'update_draft')
->where('page_id', '=', $pageId)
->orderBy('created_at', 'desc');
}
}

View File

@@ -1,46 +0,0 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Activity\Models\View;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class Popular extends EntityQuery
{
public function run(int $count, int $page, array $filterModels = null)
{
$query = $this->permissionService()
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');
if ($filterModels) {
$query->whereIn('viewable_type', $this->entityProvider()->getMorphClasses($filterModels));
}
$entities = $query->with('viewable')
->skip($count * ($page - 1))
->take($count)
->get()
->pluck('viewable')
->filter();
$this->loadBooksForChildren($entities);
return $entities;
}
protected function loadBooksForChildren(Collection $entities)
{
$bookChildren = $entities->filter(fn(Entity $entity) => $entity instanceof BookChild);
$eloquent = (new \Illuminate\Database\Eloquent\Collection($bookChildren));
$eloquent->load(['book' => function (BelongsTo $query) {
$query->scopes('visible');
}]);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Builder;
/**
* Interface for our classes which provide common queries for our
* entity objects. Ideally all queries for entities should run through
* these classes.
* Any added methods should return a builder instances to allow extension
* via building on the query, unless the method starts with 'find'
* in which case an entity object should be returned.
* (nullable unless it's a *OrFail method).
*/
interface ProvidesEntityQueries
{
/**
* Start a new query for this entity type.
*/
public function start(): Builder;
/**
* Find the entity of the given ID, or return null if not found.
*/
public function findVisibleById(int $id): ?Entity;
/**
* Start a query for items that are visible, with selection
* configured for list display of this item.
*/
public function visibleForList(): Builder;
}

View File

@@ -0,0 +1,42 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Activity\Models\View;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class QueryPopular
{
public function __construct(
protected PermissionApplicator $permissions,
protected EntityProvider $entityProvider,
protected MixedEntityListLoader $listLoader,
) {
}
public function run(int $count, int $page, array $filterModels = null): Collection
{
$query = $this->permissions
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');
if ($filterModels) {
$query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels));
}
$views = $query
->skip($count * ($page - 1))
->take($count)
->get();
$this->listLoader->loadIntoRelations($views->all(), 'viewable', true);
return $views->pluck('viewable')->filter();
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Activity\Models\View;
use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator;
use Illuminate\Support\Collection;
class QueryRecentlyViewed
{
public function __construct(
protected PermissionApplicator $permissions,
protected MixedEntityListLoader $listLoader,
) {
}
public function run(int $count, int $page): Collection
{
$user = user();
if ($user->isGuest()) {
return collect();
}
$query = $this->permissions->restrictEntityRelationQuery(
View::query(),
'views',
'viewable_id',
'viewable_type'
)
->orderBy('views.updated_at', 'desc')
->where('user_id', '=', user()->id);
$views = $query
->skip(($page - 1) * $count)
->take($count)
->get();
$this->listLoader->loadIntoRelations($views->all(), 'viewable', false);
return $views->pluck('viewable')->filter();
}
}

View File

@@ -3,10 +3,18 @@
namespace BookStack\Entities\Queries;
use BookStack\Activity\Models\Favourite;
use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator;
use Illuminate\Database\Query\JoinClause;
class TopFavourites extends EntityQuery
class QueryTopFavourites
{
public function __construct(
protected PermissionApplicator $permissions,
protected MixedEntityListLoader $listLoader,
) {
}
public function run(int $count, int $skip = 0)
{
$user = user();
@@ -14,7 +22,7 @@ class TopFavourites extends EntityQuery
return collect();
}
$query = $this->permissionService()
$query = $this->permissions
->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type')
->select('favourites.*')
->leftJoin('views', function (JoinClause $join) {
@@ -25,11 +33,13 @@ class TopFavourites extends EntityQuery
->orderBy('views.views', 'desc')
->where('favourites.user_id', '=', user()->id);
return $query->with('favouritable')
$favourites = $query
->skip($skip)
->take($count)
->get()
->pluck('favouritable')
->filter();
->get();
$this->listLoader->loadIntoRelations($favourites->all(), 'favouritable', false);
return $favourites->pluck('favouritable')->filter();
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace BookStack\Entities\Queries;
use BookStack\Activity\Models\View;
use Illuminate\Support\Collection;
class RecentlyViewed extends EntityQuery
{
public function run(int $count, int $page): Collection
{
$user = user();
if ($user === null || $user->isGuest()) {
return collect();
}
$query = $this->permissionService()->restrictEntityRelationQuery(
View::query(),
'views',
'viewable_id',
'viewable_type'
)
->orderBy('views.updated_at', 'desc')
->where('user_id', '=', user()->id);
return $query->with('viewable')
->skip(($page - 1) * $count)
->take($count)
->get()
->pluck('viewable')
->filter();
}
}

View File

@@ -3,9 +3,12 @@
namespace BookStack\Entities\Repos;
use BookStack\Activity\TagRepo;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
use BookStack\Entities\Models\HasHtmlDescription;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater;
@@ -20,6 +23,7 @@ class BaseRepo
protected ImageRepo $imageRepo,
protected ReferenceUpdater $referenceUpdater,
protected ReferenceStore $referenceStore,
protected PageQueries $pageQueries,
) {
}
@@ -104,6 +108,32 @@ class BaseRepo
}
}
/**
* Update the default page template used for this item.
* Checks that, if changing, the provided value is a valid template and the user
* has visibility of the provided page template id.
*/
public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): void
{
$changing = $templateId !== intval($entity->default_template_id);
if (!$changing) {
return;
}
if ($templateId === 0) {
$entity->default_template_id = null;
$entity->save();
return;
}
$templateExists = $this->pageQueries->visibleTemplates()
->where('id', '=', $templateId)
->exists();
$entity->default_template_id = $templateExists ? $templateId : null;
$entity->save();
}
protected function updateDescription(Entity $entity, array $input): void
{
if (!in_array(HasHtmlDescription::class, class_uses($entity))) {

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