Compare commits

...

340 Commits

Author SHA1 Message Date
Dan Brown
313326b32a Updated translator & dependency attribution before release v25.11.6 2025-12-09 20:59:57 +00:00
Dan Brown
1d87b513be Deps: Updated PHP package versions 2025-12-09 20:40:48 +00:00
Dan Brown
65f7b61c1f Sessions: Ignored extra meta/dist content in history tracking
For #5925
Added tests to cover.
Extracted existing test to place with similiar sessions tests
2025-12-03 14:10:09 +00:00
Dan Brown
2fde803c76 Deps: Updated PHP package versions
Needed to update some tests due to charset casing change in Symfony 7.4
2025-12-03 13:55:00 +00:00
Dan Brown
adfac3e30e OIDC: Updated state handling to prevent loss from other requests
Which was occuring in chrome, where background requests to the PWA
manifest, or opensearch, endpoint caused OIDC to fail due to lost state
since it was only flashed to the session.
This persists it with a manual TTL.

Added tests to cover.
Manually tested against Azure.
For #5929
2025-12-03 13:34:00 +00:00
Dan Brown
9de294343d Notifications: Fixed error on comment notification
Fixes an error where a used relation (entity) on the comment was
resulting in null due to eager loading the notification when
deserializing from the queue, where Laravel was then mis-matching the
names when performing the eager loading.

For #5918
2025-11-25 21:08:45 +00:00
Dan Brown
98a09bcc37 Deps: Updated PHP packages 2025-11-25 19:55:22 +00:00
Dan Brown
22a7772c3d Env: Added storage type to default example env
Provides greater consideration to the storage type used and the fact
that it'll place images in public space by default.
2025-11-21 13:57:38 +00:00
Dan Brown
9934f85ba9 Deps: Updated PHP packages via composer 2025-11-21 13:42:50 +00:00
Dan Brown
73c6bf4f8d Images: Updated access to consider public secure_restricted
Had prevented public access for images when secure_restricted images was
enabled (and for just secure images) when app settings allowed public
access.

This considers the app public setting, and adds tests to cover extra
scenarios to prevent regression.
2025-11-21 12:09:25 +00:00
Dan Brown
47f12cc8f6 Maintenance: Fixed type issue, updated translator list 2025-11-19 14:38:35 +00:00
Dan Brown
b2f81f5c62 New translations common.php (Albanian) (#5887) 2025-11-19 14:37:04 +00:00
Dan Brown
1be2969055 Dev: Set timezone for test DB creation, added PHP 8.5 to tests
Also fixed some test namespaces
Related to #5881
2025-11-18 19:50:09 +00:00
Dan Brown
99a1d82f0a Deps: Updated PHP package versions
Also updated dev version
2025-11-18 18:36:11 +00:00
Dan Brown
f06a6de2e7 Merge pull request #5899 from BookStackApp/zip_image_handling
Exports: Updated perm checking for images in ZIP exports
2025-11-18 18:27:38 +00:00
Dan Brown
aaa28186bc Exports: Updated perm checking for images in ZIP exports
For #5885
Adds to, uses and cleans-up central permission checking in ImageService
to mirror that which would be experienced by users in the UI to result
in the same image access conditions.

Adds testing to cover.
2025-11-18 14:19:46 +00:00
Dan Brown
8ab9252f9b DB: Added extra query tests, updated db-testing scripts
Also added skipping to avif tests for environments where GD does not
have built-in AVIF support
2025-11-11 11:23:16 +00:00
Dan Brown
befc645705 DB: Added initial DB testing docker-based script 2025-11-11 10:24:56 +00:00
Dan Brown
4eb4407ef7 DB: Updated entity scope to use models dynamic table
This was hardcoded since the table was always the same, but in some
cases Laravel will auto-alias the table name (for example, when in
sub-queries) which will break MySQL 5.7 when the scope attempts to use
the table name instead of the alias.

Needs testing coverage.
For #5877
2025-11-10 19:57:18 +00:00
Dan Brown
5bf2d801cf Notifications: Fixed attempted null usage issue where int expected 2025-11-09 11:39:38 +00:00
Dan Brown
1421ba871d Updated translator & dependency attribution before release v25.11 2025-11-09 10:52:09 +00:00
Dan Brown
563828ba52 Updated translations with latest Crowdin changes (#5843) 2025-11-02 14:41:16 +00:00
Dan Brown
d40a68b411 API: Re-ordered routes, Improved navigation
Updated route order to follow some kind of logic.
Updated scrolling sidebar to not be so cut-off in various scenarios.
Added new nav helper to quick jump to specific API models.

Closes #5865
2025-11-02 14:31:08 +00:00
Dan Brown
4a57933cd1 Deps: Updated PHP composer packages 2025-11-02 13:11:38 +00:00
Dan Brown
1df850ea3e Search: Fixed formatting timeout with many term occurrences
For #5863
2025-10-31 15:55:45 +00:00
Dan Brown
7881bddce0 Merge pull request #5860 from BookStackApp/api_image_data_endpoint
API: Added endpoints for reading image data
2025-10-31 13:48:54 +00:00
Dan Brown
02d024aa32 API: Added endpoints for reading image data 2025-10-29 18:17:51 +00:00
Dan Brown
652124abaf Merge pull request #5854 from BookStackApp/efficient_search
Pagable and efficient search
2025-10-29 13:12:52 +00:00
Dan Brown
751934c84a Search: Tested changes to single-table search
Updated filters to use single table where needed.
2025-10-29 12:59:34 +00:00
Dan Brown
3fd25bd03e Search: Added pagination, updated other search uses
Also updated hydrator to be created via injection.
2025-10-28 20:37:41 +00:00
Dan Brown
f0303de2e5 Search: Improved result hydration performance 2025-10-27 18:02:54 +00:00
Dan Brown
0b26573314 Search: Started work to make search result size consistent 2025-10-27 17:23:15 +00:00
Dan Brown
c21c36e2a6 Merge pull request #5850 from BookStackApp/comments_api
API: Started building comments API endpoints
2025-10-24 15:26:55 +01:00
Dan Brown
a949900570 API: Added examples for comments
Tweaked comment repo to avoid returning a lot of extra data on API
update responses.
2025-10-24 15:14:25 +01:00
Dan Brown
9c4a9225af Comments API: Addressed failing tests and static testing 2025-10-24 14:22:53 +01:00
Dan Brown
4627dfd4f7 API: Added comment tree to pages-read endpoint
Includes tests to cover
2025-10-24 10:18:52 +01:00
Dan Brown
fcacf7cacb API: Built out tests for comment API endpoints 2025-10-23 16:52:29 +01:00
Dan Brown
cbf27d70c8 API: Added comment CUD endpoints, drafted tests
Move some checks and made some tweaks to the repo to support consistency
between API and UI.
2025-10-23 10:21:33 +01:00
Dan Brown
3ad1e31fcc API: Added comment-read endpoint, added api docs section descriptions 2025-10-22 18:44:49 +01:00
Dan Brown
082dbc9944 API: Started building comments API endpoints 2025-10-22 14:58:29 +01:00
Dan Brown
abe9c1e5a3 API Docs: Updated link to archived GitHub repo
Closes #5813
2025-10-22 14:29:02 +01:00
Dan Brown
ebf82617b8 Code: Added groovy syntax highlighting
For #5822
2025-10-21 18:34:21 +01:00
Dan Brown
2c81447c9e Deps: Updated PHP deps via composer 2025-10-21 14:47:24 +01:00
Dan Brown
8898647f78 Merge pull request #5846 from BookStackApp/page_image_nullification
Images: Added nulling of image page relation on page delete
2025-10-21 14:46:49 +01:00
Dan Brown
ea6344898f Images: Added nulling of image page relation on page delete 2025-10-21 14:12:55 +01:00
Dan Brown
0bfd79925e Merge pull request #5844 from BookStackApp/user_ids
Updated handling of deleted user ID handling in DB
2025-10-21 13:45:00 +01:00
Dan Brown
efff8700d4 DB: Addressed test issues for user ID changes
Reverted change for activities table so that a record is retained of
past activity, and added a check where the ID may be displayed to ensure
it does not mislead and accidentially reference other, newer users.
2025-10-19 19:52:15 +01:00
Dan Brown
5754acf2fb DB: Updated handling of deleted user ID handling in DB
Updated uses of user ID to nullify on delete.
Added testing to cover deletion of user relations.
Added model factories to support changes and potential other tests.
Cleans existing ID references in the DB via migration.
2025-10-19 19:10:15 +01:00
Dan Brown
4c7d6420ee DB: Aligned entity structure to a common table
As per PR #5800

* DB: Planned out new entity table format via migrations

* DB: Created entity migration logic

Made some other tweaks/fixes while testing.

* DB: Added change of entity relation columns to suit new entities table

* DB: Got most view queries working for new structure

* Entities: Started logic change to new structure

Updated base entity class, and worked through BaseRepo.
Need to go through other repos next.

Removed a couple of redundant interfaces as part of this since we can
move the logic onto the shared ContainerData model as needed.

* Entities: Been through repos to update for new format

* Entities: Updated repos to act on refreshed clones

Changes to core entity models are now done on clones to ensure clean
state before save, and those clones are returned back if changes are
needed after that action.

* Entities: Updated model classes & relations for changes

* Entities: Changed from *Data to a common "contents" system

Added smart loading from builder instances which should hydrate with
"contents()" loaded via join, while keeping the core model original.

* Entities: Moved entity description/covers to own non-model classes

Added back some interfaces.

* Entities: Removed use of contents system for data access

* Entities: Got most queries back to working order

* Entities: Reverted back to data from contents, fixed various issues

* Entities: Started addressing issues from tests

* Entities: Addressed further tests/issues

* Entities: Been through tests to get all passing in dev

Fixed issues and needed test changes along the way.

* Entities: Addressed phpstan errors

* Entities: Reviewed TODO notes

* Entities: Ensured book/shelf relation data removed on destroy

* Entities: Been through API responses & adjusted field visibility

* Entities: Added type index to massively improve query speed
2025-10-18 13:14:30 +01:00
Dan Brown
146a6c01cc Merge branch 'v25-07' into development 2025-10-05 15:28:29 +01:00
Dan Brown
f8e4ea82c6 Updated translator & dependency attribution before release v25.07.3 2025-10-05 15:26:37 +01:00
Dan Brown
047195c033 Updated translations with latest Crowdin changes (#5786) 2025-10-05 15:22:37 +01:00
Yugo Takano
a7b30c284c Add crossorigin attribute to manifest link 2025-10-05 15:18:40 +01:00
Dan Brown
c3412d8c1c Deps: Updated PHP package versions 2025-10-05 15:17:16 +01:00
Dan Brown
4db7135231 Updated translations with latest Crowdin changes (#5786) 2025-10-05 15:09:34 +01:00
Dan Brown
009d146185 Merge pull request #5820 from tfnh621/patch-1
Fix PWA manifest access behind authenticated proxies
2025-10-05 15:08:59 +01:00
Yugo Takano
fcef1a7948 Add crossorigin attribute to manifest link 2025-10-02 21:39:22 +09:00
Dan Brown
08dfff05f4 Sponsors: Updated diagrams.net sponsor level 2025-09-11 18:58:26 +01:00
Dan Brown
fc10520e10 Merge pull request #5793 from BookStackApp/role_permission_refactor
Permissions: Use of enum references and RolePermission cleanup
2025-09-10 12:16:40 +01:00
Dan Brown
a70c733f27 Permissions: Cleanup after review of enum implementation PR 2025-09-10 11:36:54 +01:00
Dan Brown
573d692a59 Permissions: Fixed check method to allow enum usage 2025-09-10 10:44:54 +01:00
Dan Brown
419dbadcfd Permissions: Updated use of helpers to use enums
Also added middlware method to Permission enum to allow easier usage
with controller middleware.
2025-09-09 09:48:19 +01:00
Dan Brown
33a0237f87 Permissions: Updated usage of controller methods to use enum 2025-09-08 18:14:38 +01:00
Dan Brown
5fc11d46d5 Permissions: Added enum usage to controller helpers
Also fixed various missing types or spelling/formatting points.
Added down action for role_permission table changes in migration.
2025-09-08 16:15:42 +01:00
Dan Brown
c8716df284 Permissions: Removed unused role-perm columns, added permission enum
Updated main permission check methods to support our new enum.
2025-09-08 15:59:25 +01:00
Dan Brown
1ac74099ca Merge pull request #5790 from BookStackApp/timezones
Timezones: Seperate display timezone and consistency update
2025-09-04 16:36:04 +01:00
Dan Brown
36cb243d5e Timezones: Updated date displays to use consistent formats 2025-09-04 16:11:35 +01:00
Dan Brown
579c1bf424 Timezones: Seperated out store & display timezones to two options 2025-09-04 15:06:58 +01:00
Dan Brown
242b7dfb1b Merge pull request #5785 from BookStackApp/phpstan_level2
PHPstan level 3
2025-09-03 15:53:11 +01:00
Dan Brown
7d1c316202 Maintenance: Updated larastan target level, fixed issues from tests 2025-09-03 15:42:50 +01:00
Dan Brown
318b486e0b Maintenance: Finished changes to meet phpstan level 3 2025-09-03 15:18:49 +01:00
Dan Brown
e05ec7da36 Maintenance: Addressed a range of phpstan level 3 issues 2025-09-03 10:47:45 +01:00
Dan Brown
cee23de6c5 Maintenance: Reached PHPstan level 2
Reworked some stuff around slugs to use interface in a better way.
Also standardised phpdoc to use @return instead of @returns
2025-09-02 16:02:52 +01:00
Dan Brown
1e34954554 Maintenance: Continued work towards PHPstan level 2
Updated html description code to be behind a proper interface.
Set new convention for mode traits/interfaces.
2025-09-02 11:10:47 +01:00
Dan Brown
5ea4e1e935 Maintenance: Removed unused comments text column
Has been redundant and unused for a about a year now.
Closes #4821
2025-09-02 10:20:10 +01:00
Dan Brown
a27ce6e915 Packages: Updated npm packages
Spent way too many hours debugging through issues from jsdom changes.
2025-08-30 22:18:09 +01:00
Dan Brown
64b06bcf61 Packages: Updated predis 2025-08-30 11:47:22 +01:00
Dan Brown
cdbac63b40 Framework: Updated to Laravel 12 2025-08-30 11:10:11 +01:00
Dan Brown
d6296ac7a5 Merge pull request #5749 from BookStackApp/admin_command_updates
Create Admin Command: New Flags
2025-08-30 10:47:14 +01:00
Dan Brown
481f356068 Updated translator & dependency attribution before release v25.07.2 2025-08-28 17:39:10 +01:00
Dan Brown
955837c9aa Packages: Upgraded php deps to latest versions 2025-08-28 15:02:26 +01:00
Dan Brown
c6e35c2e7c Merge pull request #5775 from BookStackApp/lexical_aug25
Lexical: August 2025 fixes
2025-08-28 15:00:16 +01:00
Dan Brown
0436ccfebf Updated translations with latest Crowdin changes (#5759) 2025-08-28 14:59:36 +01:00
Dan Brown
f5da31037d Lexical: Fixed details tests
Updated to use new test pattern while there.
2025-08-28 11:17:18 +01:00
Dan Brown
46613f76f6 Lexical: Added backspace handling for details
Allows more reliable removal of details block on backspace at first
child position with the details block.
2025-08-27 14:09:38 +01:00
Dan Brown
519acaf324 Lexical: Added better selection display for collapisble blocks 2025-08-27 12:51:36 +01:00
Dan Brown
849bc4d6c3 Lexical: Improved nested details interaction
- Set to open by default on insert.
- Updated selection handling not to always fully cascade to lowest
  editable child on selection, so parents can be reliably selected.
- Updated mouse handling to treat details panes like the root element,
  inserting within-details where relevant.
2025-08-26 14:45:15 +01:00
Dan Brown
ee994fa2b7 Testing: Addressed deprecation in test helper
Also updated version in phpunit config
2025-08-25 15:01:13 +01:00
Dan Brown
13a79b3f96 Shelves: Addressed book edits removing non-visible books
Tracks the non-visible existing books on change, to retain as part of
the assigned books sync.
Added test to cover.

For #5728
2025-08-25 14:17:55 +01:00
Dan Brown
7c79b10fb6 Imports: Fixed drawing IDs not being updated in content
Would leave imported content with inaccessible images in many cases (or
wrong references) although the drawing was still being uploaded &
related to the page.
Added test to cover.

For #5761
2025-08-24 14:02:21 +01:00
Dan Brown
5c481b4282 Testing: Added more deprecation output 2025-08-15 12:42:44 +01:00
Dan Brown
9443682ae4 Maintenance: Addressed a range of deprecations
Updated deps to address deprecations fixed in newer Laravel framework
version.
2025-08-15 12:20:35 +01:00
Dan Brown
0311e3d2d7 Readme: Updated sponsor link
Was leading to a 404.
2025-08-14 16:00:46 +01:00
Dan Brown
a50a256939 ZIP Exports: Fixed reference handling for images
Recent changes could mean missed references for images in non-page
locations. This fixes that, and tries to ensure images are used if we
already have a page-based image as part of the ZIP, otherwise ensure we
have a page as part of the export to attach the image to.
2025-08-11 14:19:48 +01:00
Dan Brown
4830248a1e Release: Updated licenses and translator attribution 2025-08-11 13:41:31 +01:00
Dan Brown
1256b30ad4 Updated translations with latest Crowdin changes (#5740) 2025-08-11 13:38:47 +01:00
Dan Brown
777cca76da Deps: Bumped PHP composer deps again 2025-08-11 13:36:06 +01:00
Dan Brown
a2d13124af Testing: Added mail port to testing env options
Prevents conflict with potential user-set option.
For #5755
2025-08-11 13:33:57 +01:00
Dan Brown
bd966ef99e phpstan: Address a range of level 2 issues 2025-08-09 11:09:50 +01:00
Dan Brown
a6b5733ec2 Deps: Updated PHP packages via composer 2025-08-09 10:12:24 +01:00
Dan Brown
e899066e96 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2025-08-08 17:44:40 +01:00
Dan Brown
f4f2435856 Imports: Fixed errors causing user logout on import run
Fixes #5754
2025-08-08 17:43:58 +01:00
Dan Brown
fca4a0563e Merge pull request #5753 from BookStackApp/a11y_menu_updates
A11y: Improved menu tagging
2025-08-08 17:00:07 +01:00
Dan Brown
0bc9ddd780 A11y: Updated other dropdown menus with correct tagging
Made some form improvements at the same time.
2025-08-07 16:37:18 +01:00
Dan Brown
c66f3b2a37 A11y: Improved tagging of profile menu
- Swapped toggle out to actual button.
- Ensured menu items have proper menu item role.
- Added extra roles/labels where is makes sense.
2025-08-07 14:32:20 +01:00
Dan Brown
f36e6fb929 Commands: Updated create admin skip return
Return status for skipped --initial creation will now return 2, so that
it can be identified seperate from a creation and from an error.
2025-08-07 13:16:49 +01:00
Dan Brown
7bc0d54af1 Readme: Swapped codeclimate reference for custom phpmetrics 2025-08-05 22:00:55 +01:00
Dan Brown
2eefbd21c1 Commands: Added testing for initial admin changes
- Also changed first-admin to initial.
- Updated initial handling to not require email/name to be passed, using
  defaults instead.
- Adds missing existing email use check.
2025-08-05 16:43:06 +01:00
Dan Brown
a961552c23 Commands: Updated create admin comment to accept extra flags
Added flags to target changes to the first default admin user, and to
generate a password.
This is related to #4575.
2025-08-05 13:39:30 +01:00
Dan Brown
776ec7b9e7 Updated translations with latest Crowdin changes (#5696) 2025-07-30 09:36:34 +01:00
Dan Brown
8aa6bdc8ab Updated translator & dependency attribution before release v25.07 2025-07-30 09:27:17 +01:00
Dan Brown
4ab17157b1 API: Added ZIP export endpoint comments 2025-07-30 09:13:58 +01:00
Dan Brown
6d7ffab115 Deps: Updated PHP composer dependancy versions, fixed test namespaces 2025-07-27 11:24:54 +01:00
Dan Brown
c8cfec96dc Merge pull request #5731 from BookStackApp/lexical_jul25
New WYSIWYG editor changes for July 2025
2025-07-26 10:08:44 +01:00
Dan Brown
d145efb6f6 Lexical: Updated tests after link changes 2025-07-25 14:25:02 +01:00
Dan Brown
c54101c603 Lexical: Updated URL handling, added mouse handling
- Removed URL protocol allow-list to allow any as per old editor.
- Added mouse handling, so that clicks below many last hard-to-escape
  block types will add an empty new paragraph for easy escaping &
  editing.
2025-07-25 13:58:48 +01:00
Dan Brown
865e5aecc9 Lexical: Source code input changes
- Increased default source code view size.
- Updated HTML generation to output each top-level block on its own
  line.
2025-07-24 17:24:59 +01:00
Dan Brown
ae4d1d804a Lexical: Table cell bg and format setting fixes
- Updated table cell background color setting to be stable by
  specifically using the background property over the general styles.
- Updated format shorcuts to be correct header levels as per old editor
  and format menu.
- Updated format changes to properly update UI afterwards.
2025-07-24 16:51:11 +01:00
Dan Brown
5fc19b0edf Lexical: Fixed highlight format action, changed label 2025-07-24 13:48:00 +01:00
Dan Brown
0a73b70b64 Merge pull request #5725 from BookStackApp/md_plaintext
MarkDown Editor: TypeScript Conversion & Plaintext Editor
2025-07-23 15:48:10 +01:00
Dan Brown
2668aae09b TypeScript: Updated compile target, addressed issues 2025-07-23 15:41:55 +01:00
Dan Brown
3b9c0b34ae MD Editor: Fixed plaintext dark styles, updated npm packages 2025-07-23 14:59:26 +01:00
Dan Brown
53f32849a9 MD Editor: Last tests/check over plaintext use/switching 2025-07-23 14:49:41 +01:00
Dan Brown
7ca8bdc231 MD Editor: Added custom textarea undo/redo, updated positioning methods 2025-07-23 12:17:36 +01:00
Dan Brown
6621d55f3d MD Editor: Worked to improve/fix positioning code
Still pending testing. Old logic did not work when lines would wrap, so
changing things to a character/line measuring technique.
Fixed some other isues too while testing shortcuts.
2025-07-22 16:42:47 +01:00
Dan Brown
d55db06c01 MD Editor: Added plaintext/cm switching
Also aligned the construction of the inputs where possible.
2025-07-22 10:34:29 +01:00
Dan Brown
6b4b500a33 MD Editor: Added plaintext input implementation 2025-07-21 18:53:22 +01:00
Dan Brown
5ffec2c52d MD Editor: Updated actions to use input interface 2025-07-21 14:24:51 +01:00
Dan Brown
ec07793cda MD Editor: Started work on input interface
Created implementation for codemirror, yet to use it.
2025-07-21 11:49:58 +01:00
Dan Brown
61adc735c8 MD Editor: Finished conversion to Typescript 2025-07-20 15:05:19 +01:00
Dan Brown
7bbf591a7f MD Editor: Starting conversion to typescript 2025-07-20 12:33:22 +01:00
Dan Brown
61f8d18af5 Changelog: Tweaked spacing, count and element referencing
During review of #5663
2025-07-19 14:53:02 +01:00
Dan Brown
f786d25f2e Merge branch 'enhance-changelog-textarea' of github.com:shresthkapoor7/BookStack into shresthkapoor7-enhance-changelog-textarea 2025-07-19 14:39:57 +01:00
Dan Brown
e62f4426ea Merge pull request #5721 from BookStackApp/zip_export_api_endpoints
API: ZIP Import/Export
2025-07-18 16:34:10 +01:00
Dan Brown
32ba3a591f ZIP Imports: Added API examples, finished testing
Also updated some types on a couple of controllers.
2025-07-18 16:19:14 +01:00
Dan Brown
73025719a4 ZIP Imports: Added API test cases 2025-07-18 14:05:32 +01:00
Dan Brown
d55684531f API: Added zip export tests, reorganised tests
Extracted an extra method into helper for reuse.
2025-07-18 10:58:10 +01:00
Dan Brown
d15eb129b0 API: Initial review pass of zip import/export endpoints
Review of #5592
2025-07-18 09:54:49 +01:00
Dan Brown
3626a2265b Merge branch 'development' of github.com:LM-Nishant/BookStack into LM-Nishant-development 2025-07-18 09:19:32 +01:00
Dan Brown
d13abc7e1d Mail: Removed custom symfony/mailer fork
Moved to standard symfony mailer now that my patches have been
upstreamed. This changes the config to work with the symfony option,
following the same overall logic.
Also updated testing to allow test runs via mulitple custom env options.

Closes #5636
2025-07-15 15:24:31 +01:00
Dan Brown
2442829ef2 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2025-07-14 14:18:51 +01:00
Dan Brown
795b28162a Readme: Added SiteSpeakAI sponsor 2025-07-14 14:18:24 +01:00
Dan Brown
31706ea06b Merge pull request #5689 from BookStackApp/permission_table_locking
Better parallel permission gen handling
2025-07-09 18:02:15 +01:00
Dan Brown
4b9e6042d5 Merge pull request #5676 from BookStackApp/lexical_comments
New WYSIWYG editor for comments & descriptions
2025-07-09 18:01:25 +01:00
Dan Brown
d279b0830b Merge pull request #5685 from BookStackApp/sidebar_rejig
Tri-layout sidebar enhancements
2025-07-09 18:00:56 +01:00
Dan Brown
181ab91b1d Merge pull request #5681 from BookStackApp/parent_tag_classes
Parent tag classes
2025-07-09 17:58:13 +01:00
Dan Brown
306f41b6f0 Updated translator & dependency attribution before release v25.05.2 2025-07-07 14:59:07 +01:00
Dan Brown
c1d76d2571 Updated translations with latest Crowdin changes (#5695) 2025-07-07 14:51:45 +01:00
Dan Brown
f83074d50e Languages: Added Nepali as a language option 2025-07-07 14:43:21 +01:00
Dan Brown
2be892be70 Updated translations with latest Crowdin changes (#5659) 2025-07-07 14:35:19 +01:00
Dan Brown
c934b9319f PHP: Updated composer packages
Main intent was to get latest ssddanbrown/htmldiff version so better
handle non-ascii languages.
2025-07-07 14:24:04 +01:00
Dan Brown
35a51197ce Perms: Fixed some issues made when adding transactions 2025-07-06 22:52:06 +01:00
Dan Brown
47fd578edb Perms: Added transactions around permission effecting actions 2025-07-02 22:25:59 +01:00
Dan Brown
add091305c Perms: Removed entity perm regen on general update
Should not be needed here as this is not directly used for information
which should impact permissions.
Been through uses to ensure that this is the case.
2025-07-02 12:15:25 +01:00
Dan Brown
3d017594a8 Opensearch: Fixed XML declaration when php short tags enabled
For #5673
2025-07-01 11:29:16 +01:00
Dan Brown
0dcb2ec78c Layout: Converted tri-layout component to ts 2025-06-30 15:36:27 +01:00
Dan Brown
9186e77d27 Layout: Added scroll fade to the sidebars 2025-06-30 14:10:48 +01:00
Dan Brown
6045aff33a Layout: Improved sidebar sizing, and dropdown consideration
- Updated tri-layout sidebars to have less padding and to avoid cutting
  off content when in single-sidebar mode.
- Updated dropdown handling to consider the parent scroll container when
  deciding to drop upwards, to help prevent cut-off.
2025-06-30 13:19:45 +01:00
Dan Brown
dca9765d5d Customization: Added parent tag classes
For #5217
2025-06-28 22:27:28 +01:00
Dan Brown
a37d0c57dc Tests: Updated comment test to account for new editor usage 2025-06-27 10:33:28 +01:00
Dan Brown
054475135a Lexical: Added some styling and tweaks for basic editors 2025-06-27 10:19:45 +01:00
Dan Brown
02a35b6db4 Lexical: Added new WYSIWYG to chapter/book/shelf descriptions 2025-06-26 11:00:17 +01:00
Dan Brown
b80992ca59 Comments: Switched to lexical editor
Required a lot of changes to provide at least a decent attempt at proper
editor teardown control.
Also updates HtmlDescriptionFilter and testing to address issue with bad
child iteration which could lead to missed items.
Renamed editor version from comments to basic as it'll also be used for
item descriptions.
2025-06-25 14:16:01 +01:00
Dan Brown
c606970e38 Lexical: Started comment implementation
Refactors some UI and toolbar code for better abstract use across editor
versions.
2025-06-24 17:47:53 +01:00
Dan Brown
dfeca246a0 Merge pull request #5668 from bumperbox/patch-1
CommentDisplayTest correct namespace
2025-06-23 11:57:57 +01:00
bumperbox
3476d83ecc CommentDisplayTest correct namespace
Class Entity\CommentDisplayTest located in ./tests/Entity/CommentDisplayTest.php does not comply with psr-4 autoloading standard (rule: Tests\ => ./tests). Skipping.
2025-06-23 09:31:39 +12:00
Shresth Kapoor
3617ab1540 Enhance changelog input to textarea with character counter 2025-06-18 20:10:20 -04:00
Dan Brown
c4839c783a Updated translator & dependency attribution before release v25.05.1 2025-06-17 15:29:12 +01:00
Dan Brown
a5751a584c Updated translations with latest Crowdin changes (#5637) 2025-06-17 15:16:25 +01:00
Dan Brown
f518a3be37 Search: Updated indexer to handle non-breaking-spaces
Related to #5640
2025-06-17 14:00:13 +01:00
Dan Brown
0208f066c5 Comments: Fixed update notification text
For #5642
2025-06-17 13:42:25 +01:00
Dan Brown
2d0461b63a Merge pull request #5653 from BookStackApp/v25-05-1-lexical
Lexical Fixes for v25.05.1
2025-06-17 13:36:55 +01:00
Dan Brown
b913ae703d Lexical: Media form improvements
- Allowed re-editing of existing embed HTML code.
- Handled "src" form field when video is using child source tags.
2025-06-15 20:00:28 +01:00
Dan Brown
1611b0399f Lexical: Added a media toolbar, improved toolbars and media selection
- Updated toolbars to auto-refresh ui if it attempts to update targeting
  a DOM element which no longer exists.
- Removed MediaNode dom specific click handling which was causing
  selection issues, and did not seem to be needed now.
2025-06-15 15:22:27 +01:00
Dan Brown
8d4b8ff4f3 Lexical: Fixed media resize handling
- Updating height/width setting to clear any inline CSS width/height
  rules which would override and prevent resizes showing. This was
  common when switching media from old editor.
  Added test to cover.
- Updated resizer to track node so that it is retained & displayed
  across node DOM changes, which was previously causing the
  resizer/focus to disappear.
2025-06-15 13:55:42 +01:00
Dan Brown
77a88618c2 Lexical: Fixed double-bold text, updated tests
Double bold was due to text field exporting wrapping the output in <b>
tags when the main tag would already be strong.
2025-06-14 14:50:10 +01:00
Dan Brown
8b062d4795 Lexical: Fixed strange paragraph formatting behaviour
Formatting was not persisted on empty paragraphs, and was instead based
upon last format encountered in selection.
This was due to overly-hasty removal of other formatting code, which
this got caught it.
Restored required parts from prior codebase.

Also updated inline format button active indicator to reflect formats
using the above, so correct buttons are shown as active even when just
in an empty paragraph.
2025-06-13 19:40:13 +01:00
Dan Brown
717b516341 Lexical: Made table resize handles more efficent & less buggy
Fine mouse movement and handles will now only be active when actually
within a table, otherwise less frequent mouseovers are used to track if
in/out a table.
Hides handles when out of a table, preventing a range of issues with
stray handles floating about.
2025-06-13 16:38:53 +01:00
Dan Brown
fda242d3da Lexical: Fixed tiny image resizer on image insert
Added specific focus on image insert, and updated resize handler to
watch for load events and toggle a resize once loaded.
2025-06-13 15:58:59 +01:00
Dan Brown
aac547934c Deps: Bumped composer php package versions 2025-06-13 15:28:11 +01:00
Dan Brown
5c9b90ea0d Merge branch 'development' of github.com:BookStackApp/BookStack into development 2025-05-31 12:36:21 +01:00
Dan Brown
074f193e2f Updated translation attribution and licenses before release 2025-05-31 12:35:47 +01:00
Dan Brown
7f2604c8e8 Updated translations with latest Crowdin changes (#5622) 2025-05-31 12:15:16 +01:00
Dan Brown
b71b2a4376 Cleanup: Updated deps, fixed test, update issue templates
Also removed some unused imports.
2025-05-31 12:11:00 +01:00
Dan Brown
68df43e5a8 Merge pull request #5627 from BookStackApp/lexical_20250525
Lexical Editor: Further fixes
2025-05-28 22:53:03 +01:00
Dan Brown
c5ca865723 Lexical: Updated WYSIWYG editor status from alpha to beta 2025-05-28 22:52:09 +01:00
Dan Brown
b862f12a50 Lexical: Further improvements to table selection and captions
- Fixed errors with selection and range handling due to captions
  existing.
- Updated TableNode change handling to update existing DOM instead of
  re-creating, which avoids breaking an attached selection helper.
  - To support, Added function to handle node change detection and apply
    relevant dom updates for common properties.
2025-05-28 22:47:39 +01:00
Dan Brown
b0f8b11054 Comments: Fixed tab focus change & button placement on form usage
Fixes issue of tabs jumping back to active comments when stopping a
reply to an archived comment.
Fixes button placement looking odd due to wrong location and differing
styles depending on interaction path.
2025-05-28 22:00:24 +01:00
Dan Brown
7650ebf2f9 Deps: Updated composer/npm packages, fixed test namespace 2025-05-27 15:53:46 +01:00
Dan Brown
d9ea52522e Lexical: Fixed issues with recent changes 2025-05-26 19:06:36 +01:00
Dan Brown
2e718c12e1 Lexical: Changed table esacpe handling
Avoids misuse of selectPrevious/Next as per prior commit which was then
causing problems elsewhere, and is probably best to avoid creation in
those select methods anyway.
2025-05-26 18:47:51 +01:00
Dan Brown
a43a1832f5 Lexical: Added image insert via image link paste
Specifically added to align with existing TinyMCE behaviour which was
used by some users based upon new editor feedback.
2025-05-26 18:02:53 +01:00
Dan Brown
c4f7368c1c Lexical: Fixed table column resizing changes not appearing
Also fixed some resizer zindex issues.
2025-05-26 15:19:11 +01:00
Dan Brown
2a32475541 Lexical: Made a range of selection improvements
Updated up/down handling to create where a selection candidate does not
exist, to apply to a wider scenario via the selectPrevious/Next methods.

Updated DOM selection change handling to identify single selections
within decorated nodes to select them in full, instead of losing
selection due to partial selection of their contents.

Updated table selection handling so that our colgroups are ignored for
internal selection focus handling.
2025-05-26 14:51:03 +01:00
Dan Brown
1243108e0f Lexical: Updated dropdown handling to match tinymce behaviour
Now toolbars stay open on mouse-out, and close on other toolbar open,
outside click or an accepted action.
To support:
- Added new system to track and manage open dropdowns.
- Added way for buttons to optionally emit events upon actions.
- Added way to listen for events.
- Used the above to control when dropdowns should hide on action, since
  some dont (like overflow containers and split dropdown buttons).
2025-05-25 16:28:42 +01:00
Dan Brown
3280919370 Lexical: Improved diagram selection and keyboard usage
Fixes issues where drawings could not be removed via backspace or
delete.
2025-05-25 13:21:13 +01:00
Dan Brown
d149b809b1 Merge pull request #5626 from BookStackApp/rubentalstra-development
Review of #5429, OIDC avatar fetching
2025-05-24 18:14:18 +01:00
Dan Brown
eb47e11916 Avatars: Added redirect handling image fetching
Up to 3 times.
Can be needed based upon testing with Auth0.
Should be fine as long as it's something clearly documented.
Added test to cover.
2025-05-24 18:07:25 +01:00
Dan Brown
9d6bc1ad4d Testing: Updated tests to account for recent page redirect changes 2025-05-24 16:47:01 +01:00
Dan Brown
30bf0ce632 OIDC: Updated avatar fetching to run on each login
But only where the user does not already have an avatar assigned.
This aligns with the LDAP avatar fetching logic.
2025-05-24 16:34:36 +01:00
Dan Brown
b64c9b31d5 OIDC: Added testing coverage for picture fetching 2025-05-24 14:36:36 +01:00
Dan Brown
f9dbbe5d70 OIDC: Updated picture fetch implementation during review
Review of #5429
2025-05-24 14:02:37 +01:00
Dan Brown
05f7f4cb17 Merge branch 'development' of github.com:rubentalstra/BookStack into rubentalstra-development 2025-05-24 13:28:23 +01:00
Dan Brown
454b152b95 Pages: Redirect user to view if they can't edit
For #5568
2025-05-24 12:05:17 +01:00
Dan Brown
b29fe5c46d Merge pull request #5625 from BookStackApp/avif_images
AVIF image support
2025-05-23 17:30:24 +01:00
Dan Brown
131ac29df4 Images: Added testing to cover animated avif handling 2025-05-23 17:19:34 +01:00
Dan Brown
3a9d18a6cd Images: Added base avif support
Includes handling for animated avif images like apng.
2025-05-23 16:12:03 +01:00
Dan Brown
59e2c5e52a Merge pull request #5607 from BookStackApp/system_info_endpoint
API: System info endpoint
2025-05-22 17:31:32 +01:00
Dan Brown
d29b14ebfd Merge pull request #5584 from BookStackApp/content_comments
Content Comments
2025-05-22 16:58:36 +01:00
Dan Brown
cdd446ac73 Updated translations with latest Crowdin changes (#5608) 2025-05-17 12:04:25 +01:00
Dan Brown
1dd1024eba Merge pull request #5609 from BookStackApp/5605-folder-permissions
Images: Updated local disk to have open dir perms
2025-05-17 11:49:44 +01:00
Dan Brown
752cfe2f67 CLI: Updated CLI with fixes
- Updated php packages
- Added escaping for mysql options
2025-05-17 11:47:33 +01:00
Dan Brown
25baaa8189 Deps: Updated composer packages 2025-05-17 11:40:58 +01:00
Dan Brown
d2d0331782 Readme: Replaced discord/mastodon links, reformatted badges 2025-05-14 23:15:46 +01:00
Dan Brown
8121418e18 Readme: Added phamos as sponsor 2025-05-14 22:35:59 +01:00
Dan Brown
5ab31a8191 Images: Updated local disk to have open dir perms
Closes #5605
2025-05-14 18:15:20 +01:00
Dan Brown
0e69ab1938 API: Added test to cover system info endpoint 2025-05-13 20:46:11 +01:00
Dan Brown
058007109e API: Added system read endpoint
Standardised logic for reading app version to its own static class.
2025-05-13 20:38:08 +01:00
Dan Brown
32b29fcdfc Comments: Fixed pointer display, Fixed translation test 2025-05-13 12:03:15 +01:00
Dan Brown
8f92b6f21b Comments: Fixed a range of TS errors + other
- Migrated toolbox component to TS
- Aligned how custom event types are managed
- Fixed PHP use of content_ref where not provided
2025-05-12 15:31:55 +01:00
Dan Brown
62f78f1c6d Comments: Split tests, added extra archive/reference tests 2025-05-12 14:26:09 +01:00
Dan Brown
f8c0aaff03 Comments: Checked content/arhived comment styles in dark mode
Also added default non-clickable styles for scenarios for references
which don't have an active content link.
2025-05-09 14:17:04 +01:00
Dan Brown
a27df485bb Comments: Fixed display, added archive list support for editor toolbox 2025-05-09 12:14:28 +01:00
Dan Brown
3e99ce4098 Deps: Updated PHP packages
Mainly to update termwind which was causing issues for users on Arch
where a more recent libxml version was in use.
2025-05-08 15:53:25 +01:00
Dan Brown
ce1e20501c Updated translator & dependency attribution before release v25.02.3 2025-05-05 18:14:18 +01:00
Dan Brown
295532fa7a Deps: Updated PHP packages 2025-05-05 18:09:49 +01:00
Dan Brown
642ba668b1 Merge pull request #5601 from BookStackApp/file_permissions
Images: Changed how new image permissions are set
2025-05-05 12:54:40 +01:00
Dan Brown
4f36cdd757 Updated translations with latest Crowdin changes (#5566) 2025-05-05 12:24:12 +01:00
Dan Brown
8821844c4a Exports: Fixed CSS file BOM mark breaking CSS variables in exports
Adds a dummy CSS rule to break as the first rule, instead of our
:root variables.
Fixes #5576
2025-05-05 12:21:32 +01:00
Dan Brown
1262083fcf Images: Changed how new image permissions are set
Removed default public visibility for images at the driver level,
leaving only doing this as a specific action in the logic.
Added try/catch around permission setting so that
permission-incompatible environments won't fatally fail, but instead
log a warning.

Tested via a google cloud storage bucket FUSE mount, mounted under another
user but with open 777 permissions.

Related to #5269
2025-05-03 20:30:50 +01:00
Dan Brown
c82fa33210 Comments: Further range of content reference ux improvements
- Added reference indicator to comment create form.
  - Added remove action.
- Extracted reference text to translations.
- Changed reference hash to be text-based instead of HTML based.
- Added reference display for newly added comments.
- Handled reference marker delete on comment delete.
2025-05-01 17:22:12 +01:00
Dan Brown
15c79c38db Comments: Addressed a range of edge cases and ux issues for references
Handles only display and handling references when they're in the active
tab, while handling proper removal when made not visible.
2025-05-01 16:33:42 +01:00
Dan Brown
e7dcc2dcdf Comments: Moved to tab UI, Converted tabs component to ts 2025-04-30 17:42:09 +01:00
Dan Brown
099f6104d0 Comments: Started archive display, created mode for tree node 2025-04-28 20:09:18 +01:00
Dan Brown
8bdf948743 Comments: Added archive endpoints, messages, Js actions and tests 2025-04-28 15:37:09 +01:00
Dan Brown
e8f44186a8 Comments: Split out page comment reference logic to own component
Started support for editor view.
Moved comment elements to be added relative to content area instad of
specific target reference element.
Added relocating on screen size change.
2025-04-27 16:51:24 +01:00
Dan Brown
ecda4e1d6f Comments: Added reference marker to comments 2025-04-26 21:05:54 +01:00
nchoudhary@logicmines.in
64da80cbf4 added routes for zip export 2025-04-25 13:00:06 +05:30
nchoudhary@logicmines.in
5fa728f28a Develop functionality to import ZIP files. Create an API controller and define a route entry for handling the import process. Implement logic to read the list of files within the ZIP, process the directory structure, and automatically create associated pages, chapters, and books based on the ZIP file's contents. 2025-04-25 12:48:34 +05:30
nchoudhary@logicmines.in
c61ce8dee4 Implement functionality to export a book, along with its pages and chapters, as a ZIP file. 2025-04-25 12:45:09 +05:30
Dan Brown
f656a82fe7 Comments: Styled content comments & improved interaction 2025-04-24 13:21:23 +01:00
Dan Brown
5bfba281fc Comments: Started inline comment display windows 2025-04-21 14:04:41 +01:00
Dan Brown
18ede9bbd3 Comments: Added inline comment marker/highlight logic 2025-04-19 14:07:52 +01:00
Dan Brown
2e7544a865 Comments: Converted comment component to TS 2025-04-19 12:46:47 +01:00
Dan Brown
5e3c3ad634 Comments: Added back-end content reference handling
Also added archived property, to be added.
2025-04-18 21:13:49 +01:00
Dan Brown
add238fe9f Comments & Pointer: Converted components to typescript
Made changes for dom and translation services for easier usage
considering types.
trans_choice updated to allow default count replacement data as per
Laravel's default behaviour.
2025-04-18 20:42:56 +01:00
Dan Brown
8d159f77e4 Comments: Started logic for content references
Adds button for comments to pointer.
Adds logic to generate a content reference point.
2025-04-18 15:01:57 +01:00
Dan Brown
fa566f156a Updated translator & dependency attribution before release v25.02.2 2025-04-02 17:30:43 +01:00
Dan Brown
78a0a2f519 Merge pull request #5558 from BookStackApp/lexical_round3
Lexical Fixes: Round 3
2025-04-02 17:23:38 +01:00
Dan Brown
42cbd6adef Updated translations with latest Crowdin changes (#5537) 2025-04-02 17:19:34 +01:00
Dan Brown
6117349893 Deps: Updated composer packages 2025-04-02 15:30:31 +01:00
Dan Brown
1256320c72 Merge branch 'bernardo-campos/development' into development 2025-04-02 15:18:31 +01:00
Dan Brown
1ba0d26fdd Sort Rules: Updated name comparison to not ignore non-ascii chars
Related to #5550 and #5542
2025-04-02 15:17:17 +01:00
Dan Brown
802f69cf35 Comments: Fixed missing comment timestamps
Due to deleted code during Laravel 11 upgrade.
Added test to cover.
Closes #5555
2025-03-30 17:36:48 +01:00
Dan Brown
bb44334224 Lexical: Added tests to cover recent changes
Also updated list tests to new test process.
2025-03-28 18:29:00 +00:00
Dan Brown
9bfcadd95f Lexical: Improved navigation around images/media
- Added specific handling to move/insert-up/down on arrow press.
- Prevented resize overlay from interrupting image node focus.
2025-03-28 14:30:03 +00:00
Dan Brown
62c8eb3357 Lexical: Made list selections & intendting more reliable
- Added handling to not include parent of top-most list range selection
  so that it's not also changed while not visually part of the
  selection range.
- Fixed issue where list items could be left over after unnesting, due
  to empty checks/removals occuring before all child handling.
- Added node sorting, applied to list items during nest operations so
  that selection range remains reliable.
2025-03-27 17:49:48 +00:00
Dan Brown
c03e44124a Lexical: Fixed task list parsing
Updated list DOM parsing to properly consider task list format set by
other MD/WYSIWYG editors.
2025-03-27 14:56:32 +00:00
Dan Brown
5c6671b3bf Lexical: Fixed issues with content not saving
Found that saving via Ctrl+Enter did not save as logic to load editor
output into form was bypassed, which this fixes by ensuring submit
events are raised during for this shortcut.

Submit handling also gets a timeout added since, at least in FF,
requestSubmit did not re-submit a form while in a submit event.
2025-03-27 14:13:18 +00:00
Bernardo Campos
abe7467ae5 Fix issue BookStackApp#5542 Sorting by name 2025-03-23 12:29:29 -03:00
Dan Brown
0ec0913846 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2025-03-16 12:44:42 +00:00
Dan Brown
e980564fd6 Updated translator & dependency attribution before release v25.02.1 2025-03-16 12:44:29 +00:00
Dan Brown
8a9215ecad Updated translations with latest Crowdin changes (#5505) 2025-03-16 12:25:53 +00:00
Dan Brown
304a1d8f91 Dependancies: Updated PHP composer deps 2025-03-16 12:04:19 +00:00
Dan Brown
dfbc78947f Revisions: Hid changes link for oldest revision
Just as a UX improvement to help avoid confusion, as the whole content
will be changes for this revision.

For #5454
2025-03-16 12:00:54 +00:00
Dan Brown
4f5ad171ac Config: Updated DB host to handle ipv6
Can be set via the square bracket format.
For #5464
2025-03-15 20:32:57 +00:00
Dan Brown
94b1cffa2d System CLI: Updated with new version
As per https://codeberg.org/bookstack/system-cli/pulls/21
dev/checksums folder added to support this new system.

Related to #161
2025-03-11 23:52:01 +00:00
Dan Brown
13dae24cbe Testing: Fixed issues during pre-release testing
- Updated locale list
- Fixed new name sorting not being case insensitive
- Updated license test to account for changed deps
2025-02-26 14:19:03 +00:00
Dan Brown
6211d6bcfc Updated translations with latest Crowdin changes (#5409) 2025-02-26 13:51:51 +00:00
Dan Brown
a384599cfa Meta: Updated licenses and translation attribution pre v25.02 2025-02-26 13:44:56 +00:00
Dan Brown
dca14feaaa Sorting: Fixes during testing of sort rules
- Fixed name numeric sorting not working as expected due to bad
  comparison.
- Added name numeric desc operation option.
- Added test to ensure each operating has a comparison function.
2025-02-24 16:58:59 +00:00
Dan Brown
d7ccb3ce6a Sorting: Updated text for sort rules
Removes 'Set' wording and notes application to books on change.
2025-02-23 14:41:26 +00:00
Dan Brown
6548ea4a12 JS: Upated npm deps, upgraded eslint, new eslint config
Upgraded eslint to 11, removed incompatible airbnb config as part of
process. ESlint config now in its own file.
2025-02-23 11:55:09 +00:00
Dan Brown
c3a1fabbf0 Deps & Tests: Updated PHP deps, fixed test namespaces 2025-02-23 11:30:10 +00:00
Dan Brown
d2542d6265 Merge pull request #5491 from BookStackApp/deprecations
Addressing PHP 8.4 Deprecations
2025-02-23 11:23:35 +00:00
Dan Brown
0e343c408f Merge pull request #5463 from BookStackApp/v24-12
v24-12 branch changes
2025-02-23 11:22:12 +00:00
Dan Brown
5c78f8352e Styles: Fixed breakpoint overlap
Alters common breakpoint utilities to not overlap at breakpoints which
would cause broken layout at those points.
For #5396
2025-02-23 11:19:11 +00:00
Dan Brown
35b45a2b8d LDAP: Fixed php type error when no cn provided for user
Changes default fallback for name to first DN part, otherwise the whole
DN, rather than leave as null which was causing a type error.

For #5443
2025-02-20 13:06:49 +00:00
Dan Brown
5050719ea3 PHP: Updated DOMPDF version 2025-02-17 13:37:58 +00:00
Dan Brown
5508c171db PHP: Addressed 8.4 deprecations within app itself 2025-02-17 12:45:37 +00:00
Dan Brown
3b4d3430a5 Tests: Updated failing license test 2025-02-17 12:07:23 +00:00
Dan Brown
213a86e3c0 Merge pull request #5415 from BookStackApp/more_lexical_fixes
Further Lexical Fixes
2025-02-16 15:28:55 +00:00
Dan Brown
2b746425c9 Lexical: Fixed code in lists, removed extra old alignment code
Code in lists could throw error on parse due to inner <code> tag being
parsed but not actually used within a <pre>, so this updates the
importDOM to disregard childdren for code blocks.

This also improves the invariant implementation to not be so
dev/debugger based, and to include vars in the output.
2025-02-16 15:09:33 +00:00
Dan Brown
5c15f4add2 Translations: Fixed a couple of errors in sorting en words 2025-02-16 11:27:49 +00:00
Dan Brown
92ad81429f Merge pull request #5488 from BookStackApp/search_index_updates
Search index improvements
2025-02-14 19:39:08 +00:00
Dan Brown
f1b8e857bf Searching: Added test for guillemets
To cover #5475
2025-02-14 19:30:25 +00:00
Dan Brown
c291d27c19 Merge branch 'inv-hareesh/development' into search_index_updates 2025-02-14 19:25:59 +00:00
Dan Brown
f4449928f8 Searching: Added custom tokenizer that considers soft delimiters.
This changes indexing so that a.b now indexes as "a", "b" AND "a.b"
instead of just the first two, for periods and hypens, so terms
containing those characters can be searched within.

Adds hypens as a delimiter - #2095
2025-02-14 19:01:51 +00:00
Dan Brown
45a15b4792 Searching: Split out search tests into their own dir 2025-02-14 13:24:39 +00:00
Dan Brown
2291d78382 Merge pull request #5470 from Silverlan/patch-1
Fix incorrect condition for displaying new books section
2025-02-12 18:14:28 +00:00
Dan Brown
7901ca9e6b Meta: Updated dev version and sponsor link 2025-02-11 15:52:35 +00:00
Dan Brown
a7de251876 Merge pull request #5457 from BookStackApp/sort_sets
Sort rules
2025-02-11 15:41:19 +00:00
Dan Brown
7bd89316bc Sorting: Updated sort set command, Changed sort timestamp handling
- Renamed AssignSortSetCommand to AssignSortRuleCommand, updated
  contents and testing.
- Updated sorting operations to not update timestamps if only priority
  is changed.
2025-02-11 15:29:16 +00:00
Dan Brown
b9306a9029 Sorting: Renamed sort set to sort rule
Renamed based on feedback from Tim and Script on Discord.
Also fixed flaky test
2025-02-11 14:36:25 +00:00
Dan Brown
a208c46b62 Sorting: Covered sort set management with tests 2025-02-10 17:19:49 +00:00
Dan Brown
a65701294e Sorting: Split out test class, added book autosort tests
Just for test view, actual functionality of autosort on change still
needs to be tested.
2025-02-10 13:33:10 +00:00
Dan Brown
69683d50ec Sorting: Added tests to cover AssignSortSetCommand 2025-02-09 23:24:36 +00:00
Dan Brown
37d020c083 Sorting: Addded command to apply sort sets 2025-02-09 17:44:24 +00:00
Dan Brown
ec79517493 Sorting: Added auto sort option to book sort UI
Includes indicator on books added to sort operation.
2025-02-09 15:16:18 +00:00
inv-hareesh
d938565839 Fix search issue for words inside Guillemets (« ») without spaces 2025-02-07 08:59:36 +05:30
Dan Brown
ccd94684eb Sorting: Improved sort set display, delete, added action on edit
- Changes to a sort set will now auto-apply to assinged books (basic
  chunck through all on save).
- Added book count indicator to sort set list items.
- Deletion now has confirmation and auto-handling of assigned
  books/settings.
2025-02-06 14:58:08 +00:00
Dan Brown
103a8a8e8e Meta: Updated sponsor list, licence year and readme 2025-02-05 21:17:48 +00:00
Dan Brown
c13ce18837 Sorting: Added book autosort logic 2025-02-05 16:52:20 +00:00
Dan Brown
7093daa49d Sorting: Connected up default sort setting for books 2025-02-05 14:33:46 +00:00
Dan Brown
b897af2ed0 Sorting: Finished main sort set CRUD work 2025-02-04 20:11:35 +00:00
Dan Brown
d28278bba6 Sorting: Added sort set form manager UI JS
Extracted much code to be shared with the shelf books management UI
2025-02-04 15:14:22 +00:00
Silverlan
12cc2f0689 Fix incorrect condition for displaying new books section 2025-02-03 19:01:08 +01:00
Dan Brown
bf8a84a8b1 Sorting: Started sort set routes and form 2025-02-03 16:48:57 +00:00
Dan Brown
4f5f7c10b1 Thumbnails: Fixed thumnail orientation
Prevents double rotation caused from both our own orientation handling
upon that invervention was auto-applying since v3.

Fixes #5462
2025-01-31 21:29:38 +00:00
Dan Brown
a34023f715 Sorting: Added content misses from last commit, started settings 2025-01-30 17:49:19 +00:00
Dan Brown
b2ac3e0834 Sorting: Added SortSet model & migration 2025-01-29 17:34:07 +00:00
Dan Brown
5b0cb3dd50 Sorting: Extracted URL sort helper to own class
Was only used in one place, so didn't make sense to have extra global
helper clutter.
2025-01-29 17:02:34 +00:00
Dan Brown
ac0cd9995d Sorting: Reorganised book sort code to its own directory 2025-01-29 16:40:11 +00:00
Dan Brown
7e03a973d8 Lexical: Ran a deeper check on translation use 2025-01-27 16:40:41 +00:00
Dan Brown
d89a2fdb15 Lexical: Added media src conversions
Only actuall added YT in the end.
Google had changed URL scheme, and Vimeo seems to just be something else
now, can't really browse video pages like before.
2025-01-27 14:28:27 +00:00
Dan Brown
958b537a49 Lexical: Linked table form to have caption toggle option 2025-01-22 20:39:15 +00:00
Dan Brown
8a66365d48 Lexical: Added support for table caption nodes
Needs linking up to the table form still.
2025-01-22 12:54:13 +00:00
Talstra Ruben SRSNL
da82e70ca3 Add optional OIDC avatar fetching from the “picture” claim 2025-01-20 17:21:46 +01:00
Dan Brown
04cca77ae6 Lexical: Added color picker/indicator to form fields 2025-01-18 11:12:43 +00:00
Dan Brown
c091f67db3 Lexical: Added color format custom color select
Includes tracking of selected colors via localstorage for display.
2025-01-17 11:17:51 +00:00
Dan Brown
7f5fd16dc6 Lexical: Added some general test guidance
Just to help remember the general layout/methods that we've added to
make testing easier.
2025-01-15 14:31:09 +00:00
Dan Brown
0d1a237f81 Lexical: Fixed auto-link issue
Added extra test helper to check the editor state directly via string
notation access rather than juggling types/objects to access deep
properties.
2025-01-15 14:15:58 +00:00
Dan Brown
786a434c03 Merge pull request #5405 from BookStackApp/public_theme_files
Theme System: Public serving of files
2025-01-14 14:56:43 +00:00
Dan Brown
25c4f4b02b Themes: Documented public file serving 2025-01-14 14:53:10 +00:00
Dan Brown
481580be17 Themes: Added testing and better mime sniffing for public serving
Existing mime sniffer wasn't great at distinguishing between plaintext
file types, so added a custom extension based mapping for common web
formats that may be expected to be used with this.
2025-01-13 16:51:07 +00:00
Dan Brown
593645acfe Themes: Added route to serve public theme files
Allows files to be placed within a "public" folder within a theme
directory which the contents of will served by BookStack for access.

- Only "web safe" content-types are provided.
- A static 1 day cache time it set on served files.

For #3904
2025-01-13 14:34:44 +00:00
Dan Brown
b9751807e7 Merge pull request #5400 from BookStackApp/laravel11
Laravel 11 Upgrade
2025-01-13 13:27:32 +00:00
Dan Brown
ee88832f1a Updated translations with latest Crowdin changes (#5399) 2025-01-13 13:26:04 +00:00
Dan Brown
dbda82ef92 Framework: Re-add updated patched symfony-mailer
https://github.com/ssddanbrown/symfony-mailer/commit/e9de8dccd76a63fc23475016e6574da6f5f12a2
2025-01-11 15:05:10 +00:00
Dan Brown
ad8bc5fe21 Framework: Updated phpunit to 11, updated migration test php versions 2025-01-11 13:50:01 +00:00
Dan Brown
5bf75786c6 Framework: Fixed Laravel 11 upgrade test issues, updated phpstan
- Fixed failing tests due to Laravel 11 changes
- Updated phpstan to 3.x branch
- Removed some seemingly redundant comment code, which was triggering
  phpstan.
2025-01-11 13:22:49 +00:00
Dan Brown
cf9ccfcd5b Framework: Performed Laravel 11 upgrade guide steps
Performed a little code cleanups when observed along the way.
Tested not yet ran.
2025-01-11 11:14:49 +00:00
Dan Brown
5116d83d38 PHP: Updated min version to 8.2
PHPStan config not yet compatible, but should work after moving to Laravel
11, which would allow using larastan 3.x.
2025-01-09 16:46:13 +00:00
Dan Brown
33b46882f3 Updated translations with latest Crowdin changes (#5370) 2025-01-04 21:46:35 +00:00
Dan Brown
9a5c287470 Deps: Updated composer packages 2025-01-04 21:45:36 +00:00
Dan Brown
6effc6d262 Merge pull request #5379 from BookStackApp/better_cleanup
Export limits and cleanup
2025-01-04 21:05:45 +00:00
Dan Brown
ff6c5aaecb Markdown Editor: Fixed scroll jump on image upload
For #5384
2025-01-04 21:01:28 +00:00
Dan Brown
1ff2826678 Exports: Added rate limits for UI exports
Just as a measure to prevent potential abuse of these potentially
longer-running endpoints.
Adds test to cover for ZIP exports, but applied to all formats.
2025-01-01 15:42:59 +00:00
Dan Brown
7e31725d48 Exports: Improved PDF command temp file cleanup 2025-01-01 15:19:11 +00:00
Dan Brown
6d7ff59a89 ZIP Exports: Improved temp file tracking & clean-up 2024-12-31 15:13:50 +00:00
1010 changed files with 31839 additions and 13289 deletions

View File

@@ -26,6 +26,13 @@ DB_DATABASE=database_database
DB_USERNAME=database_username
DB_PASSWORD=database_user_password
# Storage system to use
# By default files are stored on the local filesystem, with images being placed in
# public web space so they can be efficiently served directly by the web-server.
# For other options with different security levels & considerations, refer to:
# https://www.bookstackapp.com/docs/admin/upload-config/
STORAGE_TYPE=local
# Mail system to use
# Can be 'smtp' or 'sendmail'
MAIL_DRIVER=smtp

View File

@@ -36,10 +36,14 @@ APP_LANG=en
# APP_LANG will be used if such a header is not provided.
APP_AUTO_LANG_PUBLIC=true
# Application timezone
# Used where dates are displayed such as on exported content.
# Application timezones
# The first option is used to determine what timezone is used for date storage.
# Leaving that as "UTC" is advised.
# The second option is used to set the timezone which will be used for date
# formatting and display. This defaults to the "APP_TIMEZONE" value.
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
APP_TIMEZONE=UTC
APP_DISPLAY_TIMEZONE=UTC
# Application theme
# Used to specific a themes/<APP_THEME> folder where BookStack UI
@@ -56,6 +60,7 @@ APP_PROXIES=null
# Database details
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
# An ipv6 address can be used via the square bracket format ([::1]).
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=database_database

View File

@@ -42,6 +42,7 @@ body:
label: Log Content
description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below.
placeholder: Be sure to remove any confidential details in your logs
render: text
validations:
required: false
- type: textarea

View File

@@ -0,0 +1,9 @@
name: Blank Request (Maintainers Only)
description: For maintainers only - Start a blank request
body:
- type: markdown
attributes:
value: "**This blank request option is only for existing official maintainers of the project!** Please instead use a different request option. If you use this your issue will be closed off."
- type: textarea
attributes:
label: Description

View File

@@ -177,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
REMOVED_USER :: French; German; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -222,7 +222,7 @@ SmokingCrop :: Dutch
Maciej Lebiest (Szwendacz) :: Polish
DiscordDigital :: German; German Informal
Gábor Marton (dodver) :: Hungarian
Jasell :: Swedish
Jakob Åsell (Jasell) :: Swedish
Ghost_chu (ghostchu) :: Chinese Simplified
Ravid Shachar (ravidshachar) :: Hebrew
Helga Guchshenskaya (guchshenskaya) :: Russian
@@ -438,7 +438,7 @@ javadataherian :: Persian
Ludo-code :: French
hollsten :: Swedish
Ngoc Lan Phung (lanpncz) :: Vietnamese
Worive :: Catalan
Worive :: Catalan; French
Илья Скаба (skabailya) :: Russian
Irjan Olsen (Irch) :: Norwegian Bokmal
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
@@ -461,3 +461,54 @@ Yannis Karlaftis (meliseus) :: Greek
felixxx :: German Informal
randi (randi65535) :: Korean
test65428 :: Greek
zeronell :: Chinese Simplified
julien Vinber (julienVinber) :: French
Hyunwoo Park (oksure) :: Korean
aram.rafeq.7 (aramrafeq2) :: Kurdish
Raphael Moreno (RaphaelMoreno) :: Portuguese, Brazilian
yn (user99) :: Arabic
Pavel Zlatarov (pzlatarov) :: Bulgarian
ingelres :: French
mabdullah :: Arabic
Skrabák Csaba (kekcsi) :: Hungarian
Evert Meulie (Evert) :: Norwegian Bokmal
Jasper Backer (jasperb) :: Dutch
Alexandar Cavdarovski (ace.200112) :: Swedish
구닥다리TV (yjj8353) :: Korean
Onur Oskay (o.oskay) :: Turkish
Sébastien Merveille (SebastienMerv) :: French
Maxim Kouznetsov (masya.work) :: Hebrew
neodvisnost :: Slovenian
Soubi Agatsuma (bisouya) :: Hebrew
Ilya Shaulov (ishaulov) :: Russian
Konstantin Bobkov (b.konstantv) :: Russian
Ruben Sutter (rubensutter) :: German
jellium :: French
Qxlkdr :: Swedish
Hari (muhhari) :: Indonesian
仙君御 (xjy) :: Chinese Simplified
TapioM :: Finnish
lingb58 :: Chinese Traditional
Angel Pandey (angel-pandey) :: Nepali
Supriya Shrestha (supriyashrestha) :: Nepali
gprabhat :: Nepali
CellCat :: Chinese Simplified
Al Desrahim (aldesrahim) :: Indonesian
ahmad abbaspour (deshneh.dar.diss) :: Persian
Erjon K. (ekr) :: Albanian
LiZerui (iamzrli) :: Chinese Traditional
Ticker (ticker.com) :: Hebrew
CrazyComputer :: Chinese Simplified
Firr (FirrV) :: Russian
João Faro (FaroJoaoFaro) :: Portuguese
Danilo dos Santos Barbosa (bozochegou) :: Portuguese, Brazilian
Chris (furesoft) :: German
Silvia Isern (eiendragon) :: Catalan
Dennis Kron Pedersen (ahjdp) :: Danish
iamwhoiamwhoami :: Swedish
Grogui :: French
MrCharlesIII :: Arabic
David Olsen (dawin) :: Danish
ltnzr :: French
Frank Holler (holler.frank) :: German; German Informal
Korab Arifi (korabidev) :: Albanian

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
php: ['8.2', '8.3', '8.4', '8.5']
steps:
- uses: actions/checkout@v4

View File

@@ -13,10 +13,10 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
strategy:
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
php: ['8.2', '8.3', '8.4', '8.5']
steps:
- uses: actions/checkout@v4

1
.gitignore vendored
View File

@@ -32,3 +32,4 @@ webpack-stats.json
phpstan.neon
esbuild-meta.json
.phpactor.json
/*.zip

View File

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

View File

@@ -9,11 +9,9 @@ use Illuminate\Http\Request;
class OidcController extends Controller
{
protected OidcService $oidcService;
public function __construct(OidcService $oidcService)
{
$this->oidcService = $oidcService;
public function __construct(
protected OidcService $oidcService
) {
$this->middleware('guard:oidc');
}
@@ -30,7 +28,7 @@ class OidcController extends Controller
return redirect('/login');
}
session()->flash('oidc_state', $loginDetails['state']);
session()->put('oidc_state', time() . ':' . $loginDetails['state']);
return redirect($loginDetails['url']);
}
@@ -41,10 +39,16 @@ class OidcController extends Controller
*/
public function callback(Request $request)
{
$storedState = session()->pull('oidc_state');
$responseState = $request->query('state');
$splitState = explode(':', session()->pull('oidc_state', ':'), 2);
if (count($splitState) !== 2) {
$splitState = [null, null];
}
if ($storedState !== $responseState) {
[$storedStateTime, $storedState] = $splitState;
$threeMinutesAgo = time() - 3 * 60;
if (!$storedState || $storedState !== $responseState || intval($storedStateTime) < $threeMinutesAgo) {
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
return redirect('/login');
@@ -62,7 +66,7 @@ class OidcController extends Controller
}
/**
* Log the user out then start the OIDC RP-initiated logout process.
* Log the user out, then start the OIDC RP-initiated logout process.
*/
public function logout()
{

View File

@@ -2,60 +2,26 @@
namespace BookStack\Access;
use BookStack\Users\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Database\Eloquent\Model;
class ExternalBaseUserProvider implements UserProvider
{
/**
* The user model.
*
* @var string
*/
protected $model;
/**
* LdapUserProvider constructor.
*/
public function __construct(string $model)
{
$this->model = $model;
}
/**
* Create a new instance of the model.
*
* @return Model
*/
public function createModel()
{
$class = '\\' . ltrim($this->model, '\\');
return new $class();
}
/**
* Retrieve a user by their unique identifier.
*
* @param mixed $identifier
*
* @return Authenticatable|null
*/
public function retrieveById($identifier)
public function retrieveById(mixed $identifier): ?Authenticatable
{
return $this->createModel()->newQuery()->find($identifier);
return User::query()->find($identifier);
}
/**
* Retrieve a user by their unique identifier and "remember me" token.
*
* @param mixed $identifier
* @param string $token
*
* @return Authenticatable|null
*/
public function retrieveByToken($identifier, $token)
public function retrieveByToken(mixed $identifier, $token): null
{
return null;
}
@@ -75,32 +41,25 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Retrieve a user by the given credentials.
*
* @param array $credentials
*
* @return Authenticatable|null
*/
public function retrieveByCredentials(array $credentials)
public function retrieveByCredentials(array $credentials): ?Authenticatable
{
// Search current user base by looking up a uid
$model = $this->createModel();
return $model->newQuery()
return User::query()
->where('external_auth_id', $credentials['external_auth_id'])
->first();
}
/**
* Validate a user against the given credentials.
*
* @param Authenticatable $user
* @param array $credentials
*
* @return bool
*/
public function validateCredentials(Authenticatable $user, array $credentials)
public function validateCredentials(Authenticatable $user, array $credentials): bool
{
// Should be done in the guard.
return false;
}
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
{
// No action to perform, any passwords are external in the auth system
}
}

View File

@@ -3,23 +3,18 @@
namespace BookStack\Access\Guards;
/**
* Saml2 Session Guard.
* External Auth Session Guard.
*
* The saml2 login process is async in nature meaning it does not fit very well
* into the default laravel 'Guard' auth flow. Instead most of the logic is done
* via the Saml2 controller & Saml2Service. This class provides a safer, thin
* version of SessionGuard.
* The login process for external auth (SAML2/OIDC) is async in nature, meaning it does not fit very well
* into the default laravel 'Guard' auth flow. Instead, most of the logic is done via the relevant
* controller and services. This class provides a safer, thin version of SessionGuard.
*/
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
{
/**
* Validate a user's credentials.
*
* @param array $credentials
*
* @return bool
*/
public function validate(array $credentials = [])
public function validate(array $credentials = []): bool
{
return false;
}
@@ -27,12 +22,9 @@ class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
*
* @return bool
*/
public function attempt(array $credentials = [], $remember = false)
public function attempt(array $credentials = [], $remember = false): bool
{
return false;
}

View File

@@ -4,7 +4,7 @@ namespace BookStack\Access\Guards;
use BookStack\Access\RegistrationService;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Session\Session;
@@ -24,43 +24,31 @@ class ExternalBaseSessionGuard implements StatefulGuard
* The name of the Guard. Typically "session".
*
* Corresponds to guard name in authentication configuration.
*
* @var string
*/
protected $name;
protected readonly string $name;
/**
* The user we last attempted to retrieve.
*
* @var \Illuminate\Contracts\Auth\Authenticatable
*/
protected $lastAttempted;
protected Authenticatable|null $lastAttempted;
/**
* The session used by the guard.
*
* @var \Illuminate\Contracts\Session\Session
*/
protected $session;
protected Session $session;
/**
* Indicates if the logout method has been called.
*
* @var bool
*/
protected $loggedOut = false;
protected bool $loggedOut = false;
/**
* Service to handle common registration actions.
*
* @var RegistrationService
*/
protected $registrationService;
protected RegistrationService $registrationService;
/**
* Create a new authentication guard.
*
* @return void
*/
public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService)
{
@@ -72,13 +60,11 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Get the currently authenticated user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function user()
public function user(): Authenticatable|null
{
if ($this->loggedOut) {
return;
return null;
}
// If we've already retrieved the user for the current request we can just
@@ -101,13 +87,11 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Get the ID for the currently authenticated user.
*
* @return int|null
*/
public function id()
public function id(): int|null
{
if ($this->loggedOut) {
return;
return null;
}
return $this->user()
@@ -117,12 +101,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Log a user into the application without sessions or cookies.
*
* @param array $credentials
*
* @return bool
*/
public function once(array $credentials = [])
public function once(array $credentials = []): bool
{
if ($this->validate($credentials)) {
$this->setUser($this->lastAttempted);
@@ -135,12 +115,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Log the given user ID into the application without sessions or cookies.
*
* @param mixed $id
*
* @return \Illuminate\Contracts\Auth\Authenticatable|false
*/
public function onceUsingId($id)
public function onceUsingId($id): Authenticatable|false
{
if (!is_null($user = $this->provider->retrieveById($id))) {
$this->setUser($user);
@@ -153,38 +129,26 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Validate a user's credentials.
*
* @param array $credentials
*
* @return bool
*/
public function validate(array $credentials = [])
public function validate(array $credentials = []): bool
{
return false;
}
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
*
* @return bool
* @param bool $remember
*/
public function attempt(array $credentials = [], $remember = false)
public function attempt(array $credentials = [], $remember = false): bool
{
return false;
}
/**
* Log the given user ID into the application.
*
* @param mixed $id
* @param bool $remember
*
* @return \Illuminate\Contracts\Auth\Authenticatable|false
*/
public function loginUsingId($id, $remember = false)
public function loginUsingId(mixed $id, $remember = false): Authenticatable|false
{
// Always return false as to disable this method,
// Logins should route through LoginService.
@@ -194,12 +158,9 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Log a user into the application.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param bool $remember
*
* @return void
* @param bool $remember
*/
public function login(AuthenticatableContract $user, $remember = false)
public function login(Authenticatable $user, $remember = false): void
{
$this->updateSession($user->getAuthIdentifier());
@@ -208,12 +169,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Update the session with the given ID.
*
* @param string $id
*
* @return void
*/
protected function updateSession($id)
protected function updateSession(string|int $id): void
{
$this->session->put($this->getName(), $id);
@@ -222,10 +179,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Log the user out of the application.
*
* @return void
*/
public function logout()
public function logout(): void
{
$this->clearUserDataFromStorage();
@@ -239,62 +194,48 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Remove the user data from the session and cookies.
*
* @return void
*/
protected function clearUserDataFromStorage()
protected function clearUserDataFromStorage(): void
{
$this->session->remove($this->getName());
}
/**
* Get the last user we attempted to authenticate.
*
* @return \Illuminate\Contracts\Auth\Authenticatable
*/
public function getLastAttempted()
public function getLastAttempted(): Authenticatable
{
return $this->lastAttempted;
}
/**
* Get a unique identifier for the auth session value.
*
* @return string
*/
public function getName()
public function getName(): string
{
return 'login_' . $this->name . '_' . sha1(static::class);
}
/**
* Determine if the user was authenticated via "remember me" cookie.
*
* @return bool
*/
public function viaRemember()
public function viaRemember(): bool
{
return false;
}
/**
* Return the currently cached user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function getUser()
public function getUser(): Authenticatable|null
{
return $this->user;
}
/**
* Set the current user.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
*
* @return $this
*/
public function setUser(AuthenticatableContract $user)
public function setUser(Authenticatable $user): self
{
$this->user = $user;

View File

@@ -35,13 +35,9 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
/**
* Validate a user's credentials.
*
* @param array $credentials
*
* @throws LdapException
*
* @return bool
*/
public function validate(array $credentials = [])
public function validate(array $credentials = []): bool
{
$userDetails = $this->ldapService->getUserDetails($credentials['username']);
@@ -57,16 +53,13 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
*
* @throws LdapException*@throws \BookStack\Exceptions\JsonDebugException
* @throws LdapException
* @throws LoginAttemptException
* @throws JsonDebugException
*
* @return bool
*/
public function attempt(array $credentials = [], $remember = false)
public function attempt(array $credentials = [], $remember = false): bool
{
$username = $credentials['username'];
$userDetails = $this->ldapService->getUserDetails($username);

View File

@@ -54,7 +54,7 @@ class Ldap
*
* @return \LDAP\Result|array|false
*/
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = null)
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = [])
{
return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
}
@@ -66,7 +66,7 @@ class Ldap
*
* @return \LDAP\Result|array|false
*/
public function read($ldapConnection, string $baseDn, string $filter, array $attributes = null)
public function read($ldapConnection, string $baseDn, string $filter, array $attributes = [])
{
return ldap_read($ldapConnection, $baseDn, $filter, $attributes);
}
@@ -87,7 +87,7 @@ class Ldap
*
* @param resource|\LDAP\Connection $ldapConnection
*/
public function searchAndGetEntries($ldapConnection, string $baseDn, string $filter, array $attributes = null): array|false
public function searchAndGetEntries($ldapConnection, string $baseDn, string $filter, array $attributes = []): array|false
{
$search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
@@ -99,7 +99,7 @@ class Ldap
*
* @param resource|\LDAP\Connection $ldapConnection
*/
public function bind($ldapConnection, string $bindRdn = null, string $bindPassword = null): bool
public function bind($ldapConnection, ?string $bindRdn = null, ?string $bindPassword = null): bool
{
return ldap_bind($ldapConnection, $bindRdn, $bindPassword);
}

View File

@@ -112,10 +112,14 @@ class LdapService
return null;
}
$userCn = $this->getUserResponseProperty($user, 'cn', null);
$nameDefault = $this->getUserResponseProperty($user, 'cn', null);
if (is_null($nameDefault)) {
$nameDefault = ldap_explode_dn($user['dn'], 1)[0] ?? $user['dn'];
}
$formatted = [
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
'name' => $this->getUserDisplayName($user, $displayNameAttrs, $userCn),
'name' => $this->getUserDisplayName($user, $displayNameAttrs, $nameDefault),
'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,

View File

@@ -9,6 +9,7 @@ use BookStack\Exceptions\LoginAttemptInvalidUserException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Permissions\Permission;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use Exception;
@@ -50,7 +51,7 @@ class LoginService
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
// Authenticate on all session guards if a likely admin
if ($user->can('users-manage') && $user->can('user-roles-manage')) {
if ($user->can(Permission::UsersManage) && $user->can(Permission::UserRolesManage)) {
$guards = ['standard', 'ldap', 'saml2', 'oidc'];
foreach ($guards as $guard) {
auth($guard)->login($user);
@@ -95,7 +96,7 @@ class LoginService
{
$value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
if (!$value) {
return ['user_id' => null, 'method' => null];
return ['user_id' => null, 'method' => null, 'remember' => false];
}
[$id, $method, $remember, $time] = explode(':', $value);
@@ -103,18 +104,18 @@ class LoginService
if ($time < $hourAgo) {
$this->clearLastLoginAttempted();
return ['user_id' => null, 'method' => null];
return ['user_id' => null, 'method' => null, 'remember' => false];
}
return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)];
}
/**
* Set the last login attempted user.
* Set the last login-attempted user.
* Must be only used when credentials are correct and a login could be
* achieved but a secondary factor has stopped the login.
* achieved, but a secondary factor has stopped the login.
*/
protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember)
protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember): void
{
session()->put(
self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,

View File

@@ -11,7 +11,6 @@ class MfaSession
*/
public function isRequiredForUser(User $user): bool
{
// TODO - Test both these cases
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
}

View File

@@ -4,6 +4,7 @@ namespace BookStack\Access\Mfa;
use BookStack\Users\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
@@ -16,6 +17,8 @@ use Illuminate\Database\Eloquent\Model;
*/
class MfaValue extends Model
{
use HasFactory;
protected static $unguarded = true;
const METHOD_TOTP = 'totp';

View File

@@ -11,6 +11,7 @@ use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents;
use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
@@ -26,7 +27,8 @@ class OidcService
protected RegistrationService $registrationService,
protected LoginService $loginService,
protected HttpRequestService $http,
protected GroupSyncService $groupService
protected GroupSyncService $groupService,
protected UserAvatars $userAvatars
) {
}
@@ -220,6 +222,10 @@ class OidcService
throw new OidcException($exception->getMessage());
}
if ($this->config()['fetch_avatar'] && !$user->avatar()->exists() && $userDetails->picture) {
$this->userAvatars->assignToUserFromUrl($user, $userDetails->picture);
}
if ($this->shouldSyncGroups()) {
$detachExisting = $this->config()['remove_from_groups'];
$this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);

View File

@@ -11,6 +11,7 @@ class OidcUserDetails
public ?string $email = null,
public ?string $name = null,
public ?array $groups = null,
public ?string $picture = null,
) {
}
@@ -40,15 +41,16 @@ class OidcUserDetails
$this->email = $claims->getClaim('email') ?? $this->email;
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
$this->picture = static::getPicture($claims) ?: $this->picture;
}
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $token): string
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $claims): string
{
$displayNameClaimParts = explode('|', $displayNameClaims);
$displayName = [];
foreach ($displayNameClaimParts as $claim) {
$component = $token->getClaim(trim($claim)) ?? '';
$component = $claims->getClaim(trim($claim)) ?? '';
if ($component !== '') {
$displayName[] = $component;
}
@@ -57,13 +59,13 @@ class OidcUserDetails
return implode(' ', $displayName);
}
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): ?array
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $claims): ?array
{
if (empty($groupsClaim)) {
return null;
}
$groupsList = Arr::get($token->getAllClaims(), $groupsClaim);
$groupsList = Arr::get($claims->getAllClaims(), $groupsClaim);
if (!is_array($groupsList)) {
return null;
}
@@ -72,4 +74,14 @@ class OidcUserDetails
return is_string($val);
}));
}
protected static function getPicture(ProvidesClaims $claims): ?string
{
$picture = $claims->getClaim('picture');
if (is_string($picture) && str_starts_with($picture, 'http')) {
return $picture;
}
return null;
}
}

View File

@@ -51,7 +51,7 @@ class Saml2Service
* Returns the SAML2 request ID, and the URL to redirect the user to.
*
* @throws Error
* @returns array{url: string, id: ?string}
* @return array{url: string, id: ?string}
*/
public function logout(User $user): array
{

View File

@@ -5,18 +5,23 @@ namespace BookStack\Access;
use BookStack\Activity\Models\Loggable;
use BookStack\App\Model;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Class SocialAccount.
*
* @property string $driver
* @property User $user
*/
class SocialAccount extends Model implements Loggable
{
protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
use HasFactory;
public function user()
protected $fillable = ['user_id', 'driver', 'driver_id'];
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

View File

@@ -55,7 +55,7 @@ class SocialDriverManager
/**
* Gets the names of the active social drivers, keyed by driver id.
* @returns array<string, string>
* @return array<string, string>
*/
public function getActive(): array
{
@@ -92,7 +92,7 @@ class SocialDriverManager
string $driverName,
array $config,
string $socialiteHandler,
callable $configureForRedirect = null
?callable $configureForRedirect = null
) {
$this->validDrivers[] = $driverName;
config()->set('services.' . $driverName, $config);

View File

@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
class ActivityQueries
@@ -67,6 +68,7 @@ class ActivityQueries
$activity = $query->orderBy('created_at', 'desc')
->with(['loggable' => function (Relation $query) {
/** @var MorphTo<Entity, Activity> $query */
$query->withTrashed();
}, 'user.avatar'])
->skip($count * ($page - 1))

View File

@@ -71,6 +71,10 @@ class ActivityType
const IMPORT_RUN = 'import_run';
const IMPORT_DELETE = 'import_delete';
const SORT_RULE_CREATE = 'sort_rule_create';
const SORT_RULE_UPDATE = 'sort_rule_update';
const SORT_RULE_DELETE = 'sort_rule_delete';
/**
* Get all the possible values.
*/

View File

@@ -4,8 +4,11 @@ namespace BookStack\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity as ActivityService;
use BookStack\Util\HtmlDescriptionFilter;
use Illuminate\Database\Eloquent\Builder;
class CommentRepo
{
@@ -17,11 +20,46 @@ class CommentRepo
return Comment::query()->findOrFail($id);
}
/**
* Get a comment by ID, ensuring it is visible to the user based upon access to the page
* which the comment is attached to.
*/
public function getVisibleById(int $id): Comment
{
return $this->getQueryForVisible()->findOrFail($id);
}
/**
* Start a query for comments visible to the user.
* @return Builder<Comment>
*/
public function getQueryForVisible(): Builder
{
return Comment::query()->scopes('visible');
}
/**
* Create a new comment on an entity.
*/
public function create(Entity $entity, string $html, ?int $parent_id): Comment
public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
{
// Prevent comments being added to draft pages
if ($entity instanceof Page && $entity->draft) {
throw new \Exception(trans('errors.cannot_add_comment_to_draft'));
}
// Validate parent ID
if ($parentId !== null) {
$parentCommentExists = Comment::query()
->where('commentable_id', '=', $entity->id)
->where('commentable_type', '=', $entity->getMorphClass())
->where('local_id', '=', $parentId)
->exists();
if (!$parentCommentExists) {
$parentId = null;
}
}
$userId = user()->id;
$comment = new Comment();
@@ -29,12 +67,14 @@ class CommentRepo
$comment->created_by = $userId;
$comment->updated_by = $userId;
$comment->local_id = $this->getNextLocalId($entity);
$comment->parent_id = $parent_id;
$comment->parent_id = $parentId;
$comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $contentRef) === 1 ? $contentRef : '';
$entity->comments()->save($comment);
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
$comment->refresh()->unsetRelations();
return $comment;
}
@@ -52,6 +92,45 @@ class CommentRepo
return $comment;
}
/**
* Archive an existing comment.
*/
public function archive(Comment $comment, bool $log = true): Comment
{
if ($comment->parent_id) {
throw new NotifyException('Only top-level comments can be archived.', '/', 400);
}
$comment->archived = true;
$comment->save();
if ($log) {
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
}
return $comment;
}
/**
* Un-archive an existing comment.
*/
public function unarchive(Comment $comment, bool $log = true): Comment
{
if ($comment->parent_id) {
throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
}
$comment->archived = false;
$comment->save();
if ($log) {
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
}
return $comment;
}
/**
* Delete a comment from the system.
*/

View File

@@ -4,6 +4,7 @@ namespace BookStack\Activity\Controllers;
use BookStack\Activity\Models\Activity;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
class AuditLogApiController extends ApiController
{
@@ -16,8 +17,8 @@ class AuditLogApiController extends ApiController
*/
public function list()
{
$this->checkPermission('settings-manage');
$this->checkPermission('users-manage');
$this->checkPermission(Permission::SettingsManage);
$this->checkPermission(Permission::UsersManage);
$query = Activity::query()->with(['user']);

View File

@@ -5,6 +5,8 @@ namespace BookStack\Activity\Controllers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Sorting\SortUrl;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
@@ -12,8 +14,8 @@ class AuditLogController extends Controller
{
public function index(Request $request)
{
$this->checkPermission('settings-manage');
$this->checkPermission('users-manage');
$this->checkPermission(Permission::SettingsManage);
$this->checkPermission(Permission::UsersManage);
$sort = $request->get('sort', 'activity_date');
$order = $request->get('order', 'desc');
@@ -65,6 +67,7 @@ class AuditLogController extends Controller
'filters' => $filters,
'listOptions' => $listOptions,
'activityTypes' => $types,
'filterSortUrl' => new SortUrl('settings/audit', array_filter($request->except('page')))
]);
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace BookStack\Activity\Controllers;
use BookStack\Activity\CommentRepo;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* The comment data model has a 'local_id' property, which is a unique integer ID
* scoped to the page which the comment is on. The 'parent_id' is used for replies
* and refers to the 'local_id' of the parent comment on the same page, not the main
* globally unique 'id'.
*
* If you want to get all comments for a page in a tree-like structure, as reflected in
* the UI, then that is provided on pages-read API responses.
*/
class CommentApiController extends ApiController
{
protected array $rules = [
'create' => [
'page_id' => ['required', 'integer'],
'reply_to' => ['nullable', 'integer'],
'html' => ['required', 'string'],
'content_ref' => ['string'],
],
'update' => [
'html' => ['string'],
'archived' => ['boolean'],
]
];
public function __construct(
protected CommentRepo $commentRepo,
protected PageQueries $pageQueries,
) {
}
/**
* Get a listing of comments visible to the user.
*/
public function list(): JsonResponse
{
$query = $this->commentRepo->getQueryForVisible();
return $this->apiListingResponse($query, [
'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at'
]);
}
/**
* Create a new comment on a page.
* If commenting as a reply to an existing comment, the 'reply_to' parameter
* should be provided, set to the 'local_id' of the comment being replied to.
*/
public function create(Request $request): JsonResponse
{
$this->checkPermission(Permission::CommentCreateAll);
$input = $this->validate($request, $this->rules()['create']);
$page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']);
$comment = $this->commentRepo->create(
$page,
$input['html'],
$input['reply_to'] ?? null,
$input['content_ref'] ?? '',
);
return response()->json($comment);
}
/**
* Read the details of a single comment, along with its direct replies.
*/
public function read(string $id): JsonResponse
{
$comment = $this->commentRepo->getVisibleById(intval($id));
$comment->load('createdBy', 'updatedBy');
$replies = $this->commentRepo->getQueryForVisible()
->where('parent_id', '=', $comment->local_id)
->where('commentable_id', '=', $comment->commentable_id)
->where('commentable_type', '=', $comment->commentable_type)
->get();
/** @var Comment[] $toProcess */
$toProcess = [$comment, ...$replies];
foreach ($toProcess as $commentToProcess) {
$commentToProcess->setAttribute('html', $commentToProcess->safeHtml());
$commentToProcess->makeVisible('html');
}
$comment->setRelation('replies', $replies);
return response()->json($comment);
}
/**
* Update the content or archived status of an existing comment.
*
* Only provide a new archived status if needing to actively change the archive state.
* Only top-level comments (non-replies) can be archived or unarchived.
*/
public function update(Request $request, string $id): JsonResponse
{
$comment = $this->commentRepo->getVisibleById(intval($id));
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
$input = $this->validate($request, $this->rules()['update']);
$hasHtml = isset($input['html']);
if (isset($input['archived'])) {
if ($input['archived']) {
$this->commentRepo->archive($comment, !$hasHtml);
} else {
$this->commentRepo->unarchive($comment, !$hasHtml);
}
}
if ($hasHtml) {
$comment = $this->commentRepo->update($comment, $input['html']);
}
return response()->json($comment);
}
/**
* Delete a single comment from the system.
*/
public function delete(string $id): Response
{
$comment = $this->commentRepo->getVisibleById(intval($id));
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
$this->commentRepo->delete($comment);
return response('', 204);
}
}

View File

@@ -3,8 +3,11 @@
namespace BookStack\Activity\Controllers;
use BookStack\Activity\CommentRepo;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Activity\Tools\CommentTreeNode;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -19,13 +22,14 @@ class CommentController extends Controller
/**
* Save a new comment for a Page.
*
* @throws ValidationException
* @throws ValidationException|\Exception
*/
public function savePageComment(Request $request, int $pageId)
{
$input = $this->validate($request, [
'html' => ['required', 'string'],
'parent_id' => ['nullable', 'integer'],
'content_ref' => ['string'],
]);
$page = $this->pageQueries->findVisibleById($pageId);
@@ -33,21 +37,14 @@ class CommentController extends Controller
return response('Not found', 404);
}
// Prevent adding comments to draft pages
if ($page->draft) {
return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
}
// Create a new comment.
$this->checkPermission('comment-create-all');
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null);
$this->checkPermission(Permission::CommentCreateAll);
$contentRef = $input['content_ref'] ?? '';
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef);
return view('comments.comment-branch', [
'readOnly' => false,
'branch' => [
'comment' => $comment,
'children' => [],
]
'branch' => new CommentTreeNode($comment, 0, []),
]);
}
@@ -63,8 +60,8 @@ class CommentController extends Controller
]);
$comment = $this->commentRepo->getById($commentId);
$this->checkOwnablePermission('page-view', $comment->entity);
$this->checkOwnablePermission('comment-update', $comment);
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
$comment = $this->commentRepo->update($comment, $input['html']);
@@ -74,13 +71,53 @@ class CommentController extends Controller
]);
}
/**
* Mark a comment as archived.
*/
public function archive(int $id)
{
$comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
$this->showPermissionError();
}
$this->commentRepo->archive($comment);
$tree = new CommentTree($comment->entity);
return view('comments.comment-branch', [
'readOnly' => false,
'branch' => $tree->getCommentNodeForId($id),
]);
}
/**
* Unmark a comment as archived.
*/
public function unarchive(int $id)
{
$comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
$this->showPermissionError();
}
$this->commentRepo->unarchive($comment);
$tree = new CommentTree($comment->entity);
return view('comments.comment-branch', [
'readOnly' => false,
'branch' => $tree->getCommentNodeForId($id),
]);
}
/**
* Delete a comment from the system.
*/
public function destroy(int $id)
{
$comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission('comment-delete', $comment);
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
$this->commentRepo->delete($comment);

View File

@@ -5,13 +5,14 @@ namespace BookStack\Activity\Controllers;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use Illuminate\Http\Request;
class WatchController extends Controller
{
public function update(Request $request, MixedEntityRequestHelper $entityHelper)
{
$this->checkPermission('receive-notifications');
$this->checkPermission(Permission::ReceiveNotifications);
$this->preventGuestAccess();
$requestData = $this->validate($request, array_merge([

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Webhook;
use BookStack\Activity\Queries\WebhooksAllPaginatedAndSorted;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
@@ -14,7 +15,7 @@ class WebhookController extends Controller
public function __construct()
{
$this->middleware([
'can:settings-manage',
Permission::SettingsManage->middleware()
]);
}

View File

@@ -6,6 +6,7 @@ use BookStack\App\Model;
use BookStack\Entities\Models\Entity;
use BookStack\Permissions\Models\JointPermission;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -24,6 +25,8 @@ use Illuminate\Support\Str;
*/
class Activity extends Model
{
use HasFactory;
/**
* Get the loggable model related to this activity.
* Currently only used for entities (previously entity_[id/type] columns).

View File

@@ -3,47 +3,68 @@
namespace BookStack\Activity\Models;
use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Users\Models\OwnableInterface;
use BookStack\Util\HtmlContentFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @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
* @property string $entity_type
* @property int $entity_id
* @property int $created_by
* @property int $updated_by
* @property string $commentable_type
* @property int $commentable_id
* @property string $content_ref
* @property bool $archived
*/
class Comment extends Model implements Loggable
class Comment extends Model implements Loggable, OwnableInterface
{
use HasFactory;
use HasCreatorAndUpdater;
protected $fillable = ['parent_id'];
protected $appends = ['created', 'updated'];
protected $hidden = ['html'];
protected $casts = [
'archived' => 'boolean',
];
/**
* Get the entity that this comment belongs to.
*/
public function entity(): MorphTo
{
return $this->morphTo('entity');
// We specifically define null here to avoid the different name (commentable)
// being used by Laravel eager loading instead of the method name, which it was doing
// in some scenarios like when deserialized when going through the queue system.
// So we instead specify the type and id column names to use.
// Related to:
// https://github.com/laravel/framework/pull/24815
// https://github.com/laravel/framework/issues/27342
// https://github.com/laravel/framework/issues/47953
// (and probably more)
// Ultimately, we could just align the method name to 'commentable' but that would be a potential
// breaking change and not really worthwhile in a patch due to the risk of creating extra problems.
return $this->morphTo(null, 'commentable_type', 'commentable_id');
}
/**
* Get the parent comment this is in reply to (if existing).
* @return BelongsTo<Comment, $this>
*/
public function parent(): BelongsTo
{
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
->where('entity_type', '=', $this->entity_type)
->where('entity_id', '=', $this->entity_id);
->where('commentable_type', '=', $this->commentable_type)
->where('commentable_id', '=', $this->commentable_id);
}
/**
@@ -54,29 +75,29 @@ class Comment extends Model implements Loggable
return $this->updated_at->timestamp > $this->created_at->timestamp;
}
/**
* Get created date as a relative diff.
*/
public function getCreatedAttribute(): string
{
return $this->created_at->diffForHumans();
}
/**
* Get updated date as a relative diff.
*/
public function getUpdatedAttribute(): string
{
return $this->updated_at->diffForHumans();
}
public function logDescriptor(): string
{
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})";
}
public function safeHtml(): string
{
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id')
->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type');
}
/**
* Scope the query to just the comments visible to the user based upon the
* user visibility of what has been commented on.
*/
public function scopeVisible(Builder $query): Builder
{
return app()->make(PermissionApplicator::class)
->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type');
}
}

View File

@@ -4,11 +4,14 @@ namespace BookStack\Activity\Models;
use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Favourite extends Model
{
use HasFactory;
protected $fillable = ['user_id'];
/**

View File

@@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
* @property int $id
* @property string $name
* @property string $value
* @property int $entity_id
* @property string $entity_type
* @property int $order
*/
class Tag extends Model

View File

@@ -5,6 +5,7 @@ namespace BookStack\Activity\Models;
use BookStack\Activity\WatchLevels;
use BookStack\Permissions\Models\JointPermission;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -20,6 +21,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
*/
class Watch extends Model
{
use HasFactory;
protected $guarded = [];
public function watchable(): MorphTo

View File

@@ -5,6 +5,7 @@ namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
use BookStack\Entities\Models\Entity;
use BookStack\Permissions\Permission;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Log;
@@ -19,6 +20,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
{
$users = User::query()->whereIn('id', array_unique($userIds))->get();
/** @var User $user */
foreach ($users as $user) {
// Prevent sending to the user that initiated the activity
if ($user->id === $initiator->id) {
@@ -26,7 +28,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
}
// Prevent sending of the user does not have notification permissions
if (!$user->can('receive-notifications')) {
if (!$user->can(Permission::ReceiveNotifications)) {
continue;
}

View File

@@ -27,7 +27,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
$watcherIds = $watchers->getWatcherUserIds();
// Page owner if user preferences allow
if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
if ($page->owned_by && !$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
if ($userNotificationPrefs->notifyOnOwnPageComments()) {
$watcherIds[] = $page->owned_by;
@@ -36,7 +36,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
// Parent comment creator if preferences allow
$parentComment = $detail->parent()->first();
if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
if ($parentComment && $parentComment->created_by && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
$parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
$watcherIds[] = $parentComment->created_by;

View File

@@ -20,7 +20,8 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
throw new \InvalidArgumentException("Detail for page update notifications must be a page");
}
// Get last update from activity
// Get the last update from activity
/** @var ?Activity $lastUpdate */
$lastUpdate = $detail->activity()
->where('type', '=', ActivityType::PAGE_UPDATE)
->where('id', '!=', $activity->id)
@@ -38,8 +39,8 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
$watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
$watcherIds = $watchers->getWatcherUserIds();
// Add page owner if preferences allow
if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
// Add the page owner if preferences allow
if ($detail->owned_by && !$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
$watcherIds[] = $detail->owned_by;

View File

@@ -4,14 +4,20 @@ namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
class CommentTree
{
/**
* The built nested tree structure array.
* @var array{comment: Comment, depth: int, children: array}[]
* @var CommentTreeNode[]
*/
protected array $tree;
/**
* A linear array of loaded comments.
* @var Comment[]
*/
protected array $comments;
public function __construct(
@@ -28,7 +34,7 @@ class CommentTree
public function empty(): bool
{
return count($this->tree) === 0;
return count($this->getActive()) === 0;
}
public function count(): int
@@ -36,15 +42,41 @@ class CommentTree
return count($this->comments);
}
public function get(): array
public function getActive(): array
{
return $this->tree;
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived));
}
public function activeThreadCount(): int
{
return count($this->getActive());
}
public function getArchived(): array
{
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived));
}
public function archivedThreadCount(): int
{
return count($this->getArchived());
}
public function getCommentNodeForId(int $commentId): ?CommentTreeNode
{
foreach ($this->tree as $node) {
if ($node->comment->id === $commentId) {
return $node;
}
}
return null;
}
public function canUpdateAny(): bool
{
foreach ($this->comments as $comment) {
if (userCan('comment-update', $comment)) {
if (userCan(Permission::CommentUpdate, $comment)) {
return true;
}
}
@@ -52,8 +84,17 @@ class CommentTree
return false;
}
public function loadVisibleHtml(): void
{
foreach ($this->comments as $comment) {
$comment->setAttribute('html', $comment->safeHtml());
$comment->makeVisible('html');
}
}
/**
* @param Comment[] $comments
* @return CommentTreeNode[]
*/
protected function createTree(array $comments): array
{
@@ -77,28 +118,27 @@ class CommentTree
$tree = [];
foreach ($childMap[0] ?? [] as $childId) {
$tree[] = $this->createTreeForId($childId, 0, $byId, $childMap);
$tree[] = $this->createTreeNodeForId($childId, 0, $byId, $childMap);
}
return $tree;
}
protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array
protected function createTreeNodeForId(int $id, int $depth, array &$byId, array &$childMap): CommentTreeNode
{
$childIds = $childMap[$id] ?? [];
$children = [];
foreach ($childIds as $childId) {
$children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap);
$children[] = $this->createTreeNodeForId($childId, $depth + 1, $byId, $childMap);
}
return [
'comment' => $byId[$id],
'depth' => $depth,
'children' => $children,
];
return new CommentTreeNode($byId[$id], $depth, $children);
}
/**
* @return Comment[]
*/
protected function loadComments(): array
{
if (!$this->enabled()) {

View File

@@ -0,0 +1,23 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Comment;
class CommentTreeNode
{
public Comment $comment;
public int $depth;
/**
* @var CommentTreeNode[]
*/
public array $children;
public function __construct(Comment $comment, int $depth, array $children)
{
$this->comment = $comment;
$this->depth = $depth;
$this->children = $children;
}
}

View File

@@ -3,17 +3,16 @@
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
class TagClassGenerator
{
protected array $tags;
/**
* @param Tag[] $tags
*/
public function __construct(array $tags)
{
$this->tags = $tags;
public function __construct(
protected Entity $entity
) {
}
/**
@@ -22,14 +21,23 @@ class TagClassGenerator
public function generate(): array
{
$classes = [];
$tags = $this->entity->tags->all();
foreach ($this->tags as $tag) {
$name = $this->normalizeTagClassString($tag->name);
$value = $this->normalizeTagClassString($tag->value);
$classes[] = 'tag-name-' . $name;
if ($value) {
$classes[] = 'tag-value-' . $value;
$classes[] = 'tag-pair-' . $name . '-' . $value;
foreach ($tags as $tag) {
array_push($classes, ...$this->generateClassesForTag($tag));
}
if ($this->entity instanceof BookChild && userCan(Permission::BookView, $this->entity->book)) {
$bookTags = $this->entity->book->tags;
foreach ($bookTags as $bookTag) {
array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-'));
}
}
if ($this->entity instanceof Page && $this->entity->chapter && userCan(Permission::ChapterView, $this->entity->chapter)) {
$chapterTags = $this->entity->chapter->tags;
foreach ($chapterTags as $chapterTag) {
array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-'));
}
}
@@ -41,6 +49,22 @@ class TagClassGenerator
return implode(' ', $this->generate());
}
/**
* @return string[]
*/
protected function generateClassesForTag(Tag $tag, string $prefix = ''): array
{
$classes = [];
$name = $this->normalizeTagClassString($tag->name);
$value = $this->normalizeTagClassString($tag->value);
$classes[] = "{$prefix}tag-name-{$name}";
if ($value) {
$classes[] = "{$prefix}tag-value-{$value}";
$classes[] = "{$prefix}tag-pair-{$name}-{$value}";
}
return $classes;
}
protected function normalizeTagClassString(string $value): string
{
$value = str_replace(' ', '', strtolower($value));

View File

@@ -7,6 +7,7 @@ use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
@@ -22,7 +23,7 @@ class UserEntityWatchOptions
public function canWatch(): bool
{
return $this->user->can('receive-notifications') && !$this->user->isGuest();
return $this->user->can(Permission::ReceiveNotifications) && !$this->user->isGuest();
}
public function getWatchLevel(): string

View File

@@ -50,7 +50,7 @@ class WebhookFormatter
}
if ($this->detail instanceof Model) {
$data['related_item'] = $this->formatModel();
$data['related_item'] = $this->formatModel($this->detail);
}
return $data;
@@ -83,10 +83,8 @@ class WebhookFormatter
);
}
protected function formatModel(): array
protected function formatModel(Model $model): array
{
/** @var Model $model */
$model = $this->detail;
$model->unsetRelations();
foreach ($this->modelFormatters as $formatter) {

View File

@@ -36,7 +36,7 @@ class WatchLevels
/**
* Get all the possible values as an option_name => value array.
* @returns array<string, int>
* @return array<string, int>
*/
public static function all(): array
{
@@ -50,7 +50,7 @@ class WatchLevels
/**
* Get the watch options suited for the given entity.
* @returns array<string, int>
* @return array<string, int>
*/
public static function allSuitedFor(Entity $entity): array
{

View File

@@ -2,6 +2,7 @@
namespace BookStack\Api;
use BookStack\App\AppVersion;
use BookStack\Http\ApiController;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
@@ -25,7 +26,7 @@ class ApiDocsGenerator
*/
public static function generateConsideringCache(): Collection
{
$appVersion = trim(file_get_contents(base_path('version')));
$appVersion = AppVersion::get();
$cacheKey = 'api-docs::' . $appVersion;
$isProduction = config('app.env') === 'production';
$cacheVal = $isProduction ? Cache::get($cacheKey) : null;
@@ -82,11 +83,19 @@ class ApiDocsGenerator
protected function loadDetailsFromControllers(Collection $routes): Collection
{
return $routes->map(function (array $route) {
$class = $this->getReflectionClass($route['controller']);
$method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
$comment = $method->getDocComment();
$route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null;
$route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null;
$route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
// Load class description for the model
// Not ideal to have it here on each route, but adding it in a more structured manner would break
// docs resulting JSON format and therefore be an API break.
// Save refactoring for a more significant set of changes.
$classComment = $class->getDocComment();
$route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null;
return $route;
});
}
@@ -139,7 +148,7 @@ class ApiDocsGenerator
/**
* Parse out the description text from a class method comment.
*/
protected function parseDescriptionFromMethodComment(string $comment): string
protected function parseDescriptionFromDocBlockComment(string $comment): string
{
$matches = [];
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
@@ -154,6 +163,16 @@ class ApiDocsGenerator
* @throws ReflectionException
*/
protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
{
return $this->getReflectionClass($className)->getMethod($methodName);
}
/**
* Get a reflection class from the given class name.
*
* @throws ReflectionException
*/
protected function getReflectionClass(string $className): ReflectionClass
{
$class = $this->reflectionClasses[$className] ?? null;
if ($class === null) {
@@ -161,7 +180,7 @@ class ApiDocsGenerator
$this->reflectionClasses[$className] = $class;
}
return $class->getMethod($methodName);
return $class;
}
/**

View File

@@ -4,6 +4,7 @@ namespace BookStack\Api;
use BookStack\Access\LoginService;
use BookStack\Exceptions\ApiAuthException;
use BookStack\Permissions\Permission;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
@@ -146,7 +147,7 @@ class ApiTokenGuard implements Guard
throw new ApiAuthException(trans('errors.api_user_token_expired'), 403);
}
if (!$token->user->can('access-api')) {
if (!$token->user->can(Permission::AccessApi)) {
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
}
}

View File

@@ -4,6 +4,7 @@ namespace BookStack\Api;
use BookStack\Activity\ActivityType;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Users\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
@@ -16,8 +17,8 @@ class UserApiTokenController extends Controller
*/
public function create(Request $request, int $userId)
{
$this->checkPermission('access-api');
$this->checkPermissionOrCurrentUser('users-manage', $userId);
$this->checkPermission(Permission::AccessApi);
$this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId);
$this->updateContext($request);
$user = User::query()->findOrFail($userId);
@@ -35,8 +36,8 @@ class UserApiTokenController extends Controller
*/
public function store(Request $request, int $userId)
{
$this->checkPermission('access-api');
$this->checkPermissionOrCurrentUser('users-manage', $userId);
$this->checkPermission(Permission::AccessApi);
$this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId);
$this->validate($request, [
'name' => ['required', 'max:250'],
@@ -143,8 +144,8 @@ class UserApiTokenController extends Controller
*/
protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array
{
$this->checkPermissionOr('users-manage', function () use ($userId) {
return $userId === user()->id && userCan('access-api');
$this->checkPermissionOr(Permission::UsersManage, function () use ($userId) {
return $userId === user()->id && userCan(Permission::AccessApi);
});
$user = User::query()->findOrFail($userId);

24
app/App/AppVersion.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace BookStack\App;
class AppVersion
{
protected static string $version = '';
/**
* Get the application's version number from its top-level `version` text file.
*/
public static function get(): string
{
if (!empty(static::$version)) {
return static::$version;
}
$versionFile = base_path('version');
$version = trim(file_get_contents($versionFile));
static::$version = $version;
return $version;
}
}

View File

@@ -8,7 +8,7 @@ class Model extends EloquentModel
{
/**
* Provides public access to get the raw attribute value from the model.
* Used in areas where no mutations are required but performance is critical.
* Used in areas where no mutations are required, but performance is critical.
*
* @return mixed
*/

View File

@@ -59,8 +59,8 @@ class AuthServiceProvider extends ServiceProvider
*/
public function register(): void
{
Auth::provider('external-users', function ($app, array $config) {
return new ExternalBaseUserProvider($config['model']);
Auth::provider('external-users', function () {
return new ExternalBaseUserProvider();
});
// Bind and provide the default system user as a singleton to the app instance when needed.

View File

@@ -15,7 +15,7 @@ class EventServiceProvider extends ServiceProvider
/**
* The event listener mappings for the application.
*
* @var array<class-string, array<int, class-string>>
* @var array<class-string, array<int, string>>
*/
protected $listen = [
SocialiteWasCalled::class => [
@@ -42,4 +42,12 @@ class EventServiceProvider extends ServiceProvider
{
return false;
}
/**
* Overrides the registration of Laravel's default email verification system
*/
protected function configureEmailVerification(): void
{
//
}
}

View File

@@ -85,5 +85,12 @@ class RouteServiceProvider extends ServiceProvider
RateLimiter::for('public', function (Request $request) {
return Limit::perMinute(10)->by($request->ip());
});
RateLimiter::for('exports', function (Request $request) {
$user = user();
$attempts = $user->isGuest() ? 4 : 10;
$key = $user->isGuest() ? $request->ip() : $user->id;
return Limit::perMinute($attempts)->by($key);
});
}
}

View File

@@ -3,6 +3,7 @@
namespace BookStack\App\Providers;
use BookStack\Entities\BreadcrumbsViewComposer;
use BookStack\Util\DateFormatter;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
@@ -10,6 +11,15 @@ use Illuminate\Support\ServiceProvider;
class ViewTweaksServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton(DateFormatter::class, function ($app) {
return new DateFormatter(
$app['config']->get('app.display_timezone'),
);
});
}
/**
* Bootstrap services.
*/
@@ -21,6 +31,9 @@ class ViewTweaksServiceProvider extends ServiceProvider
// View Composers
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
// View Globals
View::share('dates', $this->app->make(DateFormatter::class));
// Custom blade view directives
Blade::directive('icon', function ($expression) {
return "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>";

View File

@@ -5,11 +5,8 @@ namespace BookStack\App;
/**
* Assigned to models that can have slugs.
* Must have the below properties.
*
* @property int $id
* @property string $name
*/
interface Sluggable
interface SluggableInterface
{
/**
* Regenerate the slug for this model.

View File

@@ -0,0 +1,31 @@
<?php
namespace BookStack\App;
use BookStack\Http\ApiController;
use Illuminate\Http\JsonResponse;
class SystemApiController extends ApiController
{
/**
* Read details regarding the BookStack instance.
* Some details may be null where not set, like the app logo for example.
*/
public function read(): JsonResponse
{
$logoSetting = setting('app-logo', '');
if ($logoSetting === 'none') {
$logo = null;
} else {
$logo = $logoSetting ? url($logoSetting) : url('/logo.png');
}
return response()->json([
'version' => AppVersion::get(),
'instance_id' => setting('instance-id'),
'app_name' => setting('app-name'),
'app_logo' => $logo,
'base_url' => url('/'),
]);
}
}

View File

@@ -1,6 +1,9 @@
<?php
use BookStack\App\AppVersion;
use BookStack\App\Model;
use BookStack\Facades\Theme;
use BookStack\Permissions\Permission;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\SettingService;
use BookStack\Users\Models\User;
@@ -12,12 +15,7 @@ use BookStack\Users\Models\User;
*/
function versioned_asset(string $file = ''): string
{
static $version = null;
if (is_null($version)) {
$versionFile = base_path('version');
$version = trim(file_get_contents($versionFile));
}
$version = AppVersion::get();
$additional = '';
if (config('app.env') === 'development') {
@@ -42,9 +40,9 @@ function user(): User
* Check if the current user has a permission. If an ownable element
* is passed in the jointPermissions are checked against that particular item.
*/
function userCan(string $permission, Model $ownable = null): bool
function userCan(string|Permission $permission, ?Model $ownable = null): bool
{
if ($ownable === null) {
if (is_null($ownable)) {
return user()->can($permission);
}
@@ -58,7 +56,7 @@ function userCan(string $permission, Model $ownable = null): bool
* Check if the current user can perform the given action on any items in the system.
* Can be provided the class name of an entity to filter ability to that specific entity type.
*/
function userCanOnAny(string $action, string $entityClass = ''): bool
function userCanOnAny(string|Permission $action, string $entityClass = ''): bool
{
$permissions = app()->make(PermissionApplicator::class);
@@ -70,7 +68,7 @@ function userCanOnAny(string $action, string $entityClass = ''): bool
*
* @return mixed|SettingService
*/
function setting(string $key = null, $default = null)
function setting(?string $key = null, mixed $default = null): mixed
{
$settingService = app()->make(SettingService::class);
@@ -88,43 +86,10 @@ function setting(string $key = null, $default = null)
*/
function theme_path(string $path = ''): ?string
{
$theme = config('view.theme');
$theme = Theme::getTheme();
if (!$theme) {
return null;
}
return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path));
}
/**
* Generate a URL with multiple parameters for sorting purposes.
* Works out the logic to set the correct sorting direction
* Discards empty parameters and allows overriding.
*/
function sortUrl(string $path, array $data, array $overrideData = []): string
{
$queryStringSections = [];
$queryData = array_merge($data, $overrideData);
// Change sorting direction is already sorted on current attribute
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
} elseif (isset($overrideData['sort'])) {
$queryData['order'] = 'asc';
}
foreach ($queryData as $name => $value) {
$trimmedVal = trim($value);
if ($trimmedVal === '') {
continue;
}
$queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal);
}
if (count($queryStringSections) === 0) {
return url($path);
}
return url($path . '?' . implode('&', $queryStringSections));
}

View File

@@ -70,8 +70,8 @@ return [
// A list of the sources/hostnames that can be reached by application SSR calls.
// This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
// Host-specific functionality (usually controlled via other options) like auth
// or user avatars for example, won't use this list.
// Space seperated if multiple. Can use '*' as a wildcard.
// or user avatars, for example, won't use this list.
// Space separated if multiple. Can use '*' as a wildcard.
// Values will be compared prefix-matched, case-insensitive, against called SSR urls.
// Defaults to allow all hosts.
'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'),
@@ -80,8 +80,10 @@ return [
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
// Application timezone for back-end date functions.
// Application timezone for stored date/time values.
'timezone' => env('APP_TIMEZONE', 'UTC'),
// Application timezone for displayed date/time values in the UI.
'display_timezone' => env('APP_DISPLAY_TIMEZONE', env('APP_TIMEZONE', 'UTC')),
// Default locale to use
// A default variant is also stored since Laravel can overwrite

View File

@@ -1,37 +0,0 @@
<?php
/**
* Broadcasting 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.
*/
return [
// Default Broadcaster
// This option controls the default broadcaster that will be used by the
// framework when an event needs to be broadcast. This can be set to
// any of the connections defined in the "connections" array below.
'default' => 'null',
// Broadcast Connections
// Here you may define all of the broadcast connections that will be used
// to broadcast events to other systems or over websockets. Samples of
// each available type of connection are provided inside this array.
'connections' => [
// Default options removed since we don't use broadcasting.
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

View File

@@ -35,10 +35,6 @@ return [
// Available caches stores
'stores' => [
'apc' => [
'driver' => 'apc',
],
'array' => [
'driver' => 'array',
'serialize' => false,
@@ -49,6 +45,7 @@ return [
'table' => 'cache',
'connection' => null,
'lock_connection' => null,
'lock_table' => null,
],
'file' => [
@@ -88,6 +85,6 @@ return [
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache_'),
'prefix' => env('CACHE_PREFIX', 'bookstack_cache_'),
];

View File

@@ -40,12 +40,16 @@ if (env('REDIS_SERVERS', false)) {
// MYSQL
// Split out port from host if set
$mysql_host = env('DB_HOST', 'localhost');
$mysql_host_exploded = explode(':', $mysql_host);
$mysql_port = env('DB_PORT', 3306);
if (count($mysql_host_exploded) > 1) {
$mysql_host = $mysql_host_exploded[0];
$mysql_port = intval($mysql_host_exploded[1]);
$mysqlHost = env('DB_HOST', 'localhost');
$mysqlHostExploded = explode(':', $mysqlHost);
$mysqlPort = env('DB_PORT', 3306);
$mysqlHostIpv6 = str_starts_with($mysqlHost, '[');
if ($mysqlHostIpv6 && str_contains($mysqlHost, ']:')) {
$mysqlHost = implode(':', array_slice($mysqlHostExploded, 0, -1));
$mysqlPort = intval(end($mysqlHostExploded));
} else if (!$mysqlHostIpv6 && count($mysqlHostExploded) > 1) {
$mysqlHost = $mysqlHostExploded[0];
$mysqlPort = intval($mysqlHostExploded[1]);
}
return [
@@ -61,17 +65,17 @@ return [
'mysql' => [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'host' => $mysql_host,
'host' => $mysqlHost,
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'port' => $mysql_port,
'port' => $mysqlPort,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
// Prefixes are only semi-supported and may be unstable
// since they are not tested as part of our automated test suite.
// If used, the prefix should not be changed otherwise you will likely receive errors.
// If used, the prefix should not be changed; otherwise you will likely receive errors.
'prefix' => env('DB_TABLE_PREFIX', ''),
'prefix_indexes' => true,
'strict' => false,
@@ -88,7 +92,7 @@ return [
'database' => 'bookstack-test',
'username' => env('MYSQL_USER', 'bookstack-test'),
'password' => env('MYSQL_PASSWORD', 'bookstack-test'),
'port' => $mysql_port,
'port' => $mysqlPort,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
@@ -99,9 +103,7 @@ return [
],
// Migration Repository Table
// This table keeps track of all the migrations that have already run for
// your application. Using this information, we can determine which of
// the migrations on disk haven't actually been run in the database.
// This table keeps track of all the migrations that have already run for the application.
'migrations' => 'migrations',
// Redis configuration to use if set

View File

@@ -114,6 +114,7 @@ return [
* @var array
*/
'allowed_protocols' => [
"data://" => ["rules" => []],
'file://' => ['rules' => []],
'http://' => ['rules' => []],
'https://' => ['rules' => []],

View File

@@ -11,7 +11,7 @@
return [
// Default Filesystem Disk
// Options: local, local_secure, s3
// Options: local, local_secure, local_secure_restricted, s3
'default' => env('STORAGE_TYPE', 'local'),
// Filesystem to use specifically for image uploads.
@@ -32,20 +32,22 @@ return [
'local' => [
'driver' => 'local',
'root' => public_path(),
'visibility' => 'public',
'serve' => false,
'throw' => true,
'directory_visibility' => 'public',
],
'local_secure_attachments' => [
'driver' => 'local',
'root' => storage_path('uploads/files/'),
'serve' => false,
'throw' => true,
],
'local_secure_images' => [
'driver' => 'local',
'root' => storage_path('uploads/images/'),
'visibility' => 'public',
'serve' => false,
'throw' => true,
],

View File

@@ -11,6 +11,7 @@
// Configured mail encryption method.
// STARTTLS should still be attempted, but tls/ssl forces TLS usage.
$mailEncryption = env('MAIL_ENCRYPTION', null);
$mailPort = intval(env('MAIL_PORT', 587));
return [
@@ -33,13 +34,13 @@ return [
'transport' => 'smtp',
'scheme' => null,
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
'port' => env('MAIL_PORT', 587),
'port' => $mailPort,
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'verify_peer' => env('MAIL_VERIFY_SSL', true),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN'),
'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'),
'local_domain' => null,
'require_tls' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl' || $mailPort === 465),
],
'sendmail' => [
@@ -64,12 +65,4 @@ return [
],
],
],
// Email markdown configuration
'markdown' => [
'theme' => 'default',
'paths' => [
resource_path('views/vendor/mail'),
],
],
];

View File

@@ -47,6 +47,12 @@ return [
// Multiple values can be provided comma seperated.
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
// Enable fetching of the user's avatar from the 'picture' claim on login.
// Will only be fetched if the user doesn't already have an avatar image assigned.
// This can be a security risk due to performing server-side fetching (with up to 3 redirects) of
// data from external URLs. Only enable if you trust the OIDC auth provider to provide safe URLs for user images.
'fetch_avatar' => env('OIDC_FETCH_AVATAR', false),
// Group sync options
// Enable syncing, upon login, of OIDC groups to BookStack roles
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),

View File

@@ -23,6 +23,7 @@ return [
'database' => [
'driver' => 'database',
'connection' => null,
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,

View File

@@ -0,0 +1,99 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Entities\Models\Book;
use BookStack\Sorting\BookSorter;
use BookStack\Sorting\SortRule;
use Illuminate\Console\Command;
class AssignSortRuleCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:assign-sort-rule
{sort-rule=0: ID of the sort rule to apply}
{--all-books : Apply to all books in the system}
{--books-without-sort : Apply to only books without a sort rule already assigned}
{--books-with-sort= : Apply to only books with the sort rule of given id}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Assign a sort rule to content in the system';
/**
* Execute the console command.
*/
public function handle(BookSorter $sorter): int
{
$sortRuleId = intval($this->argument('sort-rule')) ?? 0;
if ($sortRuleId === 0) {
return $this->listSortRules();
}
$rule = SortRule::query()->find($sortRuleId);
if ($this->option('all-books')) {
$query = Book::query();
} else if ($this->option('books-without-sort')) {
$query = Book::query()->whereNull('sort_rule_id');
} else if ($this->option('books-with-sort')) {
$sortId = intval($this->option('books-with-sort')) ?: 0;
if (!$sortId) {
$this->error("Provided --books-with-sort option value is invalid");
return 1;
}
$query = Book::query()->where('sort_rule_id', $sortId);
} else {
$this->error("No option provided to specify target. Run with the -h option to see all available options.");
return 1;
}
if (!$rule) {
$this->error("Sort rule of provided id {$sortRuleId} not found!");
return 1;
}
$count = $query->clone()->count();
$this->warn("This will apply sort rule [{$rule->id}: {$rule->name}] to {$count} book(s) and run the sort on each.");
$confirmed = $this->confirm("Are you sure you want to continue?");
if (!$confirmed) {
return 1;
}
$processed = 0;
$query->chunkById(10, function ($books) use ($rule, $sorter, $count, &$processed) {
$max = min($count, ($processed + 10));
$this->info("Applying to {$processed}-{$max} of {$count} books");
foreach ($books as $book) {
$book->sort_rule_id = $rule->id;
$book->save();
$sorter->runBookAutoSort($book);
}
$processed = $max;
});
$this->info("Sort applied to {$processed} book(s)!");
return 0;
}
protected function listSortRules(): int
{
$rules = SortRule::query()->orderBy('id', 'asc')->get();
$this->error("Sort rule ID required!");
$this->warn("\nAvailable sort rules:");
foreach ($rules as $rule) {
$this->info("{$rule->id}: {$rule->name}");
}
return 1;
}
}

View File

@@ -8,7 +8,6 @@ use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Rules\Unique;
class CreateAdminCommand extends Command
{
@@ -21,7 +20,9 @@ class CreateAdminCommand extends Command
{--email= : The email address for the new admin user}
{--name= : The name of the new admin user}
{--password= : The password to assign to the new admin user}
{--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)}';
{--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)}
{--generate-password : Generate a random password for the new admin user}
{--initial : Indicate if this should set/update the details of the initial admin user}';
/**
* The console command description.
@@ -35,26 +36,12 @@ class CreateAdminCommand extends Command
*/
public function handle(UserRepo $userRepo): int
{
$details = $this->snakeCaseOptions();
if (empty($details['email'])) {
$details['email'] = $this->ask('Please specify an email address for the new admin user');
}
if (empty($details['name'])) {
$details['name'] = $this->ask('Please specify a name for the new admin user');
}
if (empty($details['password'])) {
if (empty($details['external_auth_id'])) {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
} else {
$details['password'] = Str::random(32);
}
}
$initialAdminOnly = $this->option('initial');
$shouldGeneratePassword = $this->option('generate-password');
$details = $this->gatherDetails($shouldGeneratePassword, $initialAdminOnly);
$validator = Validator::make($details, [
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
'email' => ['required', 'email', 'min:5'],
'name' => ['required', 'min:2'],
'password' => ['required_without:external_auth_id', Password::default()],
'external_auth_id' => ['required_without:password'],
@@ -68,16 +55,101 @@ class CreateAdminCommand extends Command
return 1;
}
$adminRole = Role::getSystemRole('admin');
if ($initialAdminOnly) {
$handled = $this->handleInitialAdminIfExists($userRepo, $details, $shouldGeneratePassword, $adminRole);
if ($handled !== null) {
return $handled;
}
}
$emailUsed = $userRepo->getByEmail($details['email']) !== null;
if ($emailUsed) {
$this->error("Could not create admin account.");
$this->error("An account with the email address \"{$details['email']}\" already exists.");
return 1;
}
$user = $userRepo->createWithoutActivity($validator->validated());
$user->attachRole(Role::getSystemRole('admin'));
$user->attachRole($adminRole);
$user->email_confirmed = true;
$user->save();
$this->info("Admin account with email \"{$user->email}\" successfully created!");
if ($shouldGeneratePassword) {
$this->line($details['password']);
} else {
$this->info("Admin account with email \"{$user->email}\" successfully created!");
}
return 0;
}
/**
* Handle updates to the original admin account if it exists.
* Returns an int return status if handled, otherwise returns null if not handled (new user to be created).
*/
protected function handleInitialAdminIfExists(UserRepo $userRepo, array $data, bool $generatePassword, Role $adminRole): int|null
{
$defaultAdmin = $userRepo->getByEmail('admin@admin.com');
if ($defaultAdmin && $defaultAdmin->hasSystemRole('admin')) {
if ($defaultAdmin->email !== $data['email'] && $userRepo->getByEmail($data['email']) !== null) {
$this->error("Could not create admin account.");
$this->error("An account with the email address \"{$data['email']}\" already exists.");
return 1;
}
$userRepo->updateWithoutActivity($defaultAdmin, $data, true);
if ($generatePassword) {
$this->line($data['password']);
} else {
$this->info("The default admin user has been updated with the provided details!");
}
return 0;
} else if ($adminRole->users()->count() > 0) {
$this->warn('Non-default admin user already exists. Skipping creation of new admin user.');
return 2;
}
return null;
}
protected function gatherDetails(bool $generatePassword, bool $initialAdmin): array
{
$details = $this->snakeCaseOptions();
if (empty($details['email'])) {
if ($initialAdmin) {
$details['email'] = 'admin@example.com';
} else {
$details['email'] = $this->ask('Please specify an email address for the new admin user');
}
}
if (empty($details['name'])) {
if ($initialAdmin) {
$details['name'] = 'Admin';
} else {
$details['name'] = $this->ask('Please specify a name for the new admin user');
}
}
if (empty($details['password'])) {
if (empty($details['external_auth_id'])) {
if ($generatePassword) {
$details['password'] = Str::random(32);
} else {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
}
} else {
$details['password'] = Str::random(32);
}
}
return $details;
}
protected function snakeCaseOptions(): array
{
$returnOpts = [];

View File

@@ -45,14 +45,12 @@ class UpdateUrlCommand extends Command
$columnsToUpdateByTable = [
'attachments' => ['path'],
'pages' => ['html', 'text', 'markdown'],
'chapters' => ['description_html'],
'books' => ['description_html'],
'bookshelves' => ['description_html'],
'entity_page_data' => ['html', 'text', 'markdown'],
'entity_container_data' => ['description_html'],
'page_revisions' => ['html', 'text', 'markdown'],
'images' => ['url'],
'settings' => ['value'],
'comments' => ['html', 'text'],
'comments' => ['html'],
];
foreach ($columnsToUpdateByTable as $table => $columns) {

View File

@@ -11,6 +11,7 @@ use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -47,7 +48,7 @@ class BookApiController extends ApiController
*/
public function create(Request $request)
{
$this->checkPermission('book-create-all');
$this->checkPermission(Permission::BookCreateAll);
$requestData = $this->validate($request, $this->rules()['create']);
$book = $this->bookRepo->create($requestData);
@@ -57,7 +58,7 @@ class BookApiController extends ApiController
/**
* View the details of a single book.
* The response data will contain 'content' property listing the chapter and pages directly within, in
* The response data will contain a 'content' property listing the chapter and pages directly within, in
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level
* contents will have a 'type' property to distinguish between pages & chapters.
*/
@@ -92,7 +93,7 @@ class BookApiController extends ApiController
public function update(Request $request, string $id)
{
$book = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('book-update', $book);
$this->checkOwnablePermission(Permission::BookUpdate, $book);
$requestData = $this->validate($request, $this->rules()['update']);
$book = $this->bookRepo->update($book, $requestData);
@@ -109,7 +110,7 @@ class BookApiController extends ApiController
public function delete(string $id)
{
$book = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('book-delete', $book);
$this->checkOwnablePermission(Permission::BookDelete, $book);
$this->bookRepo->destroy($book);
@@ -121,9 +122,10 @@ class BookApiController extends ApiController
$book = clone $book;
$book->unsetRelations()->refresh();
$book->load(['tags', 'cover']);
$book->makeVisible('description_html')
->setAttribute('description_html', $book->descriptionHtml());
$book->load(['tags']);
$book->makeVisible(['cover', 'description_html'])
->setAttribute('description_html', $book->descriptionInfo()->getHtml())
->setAttribute('cover', $book->coverInfo()->getImage());
return $book;
}

View File

@@ -17,7 +17,9 @@ use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use BookStack\Util\DatabaseTransaction;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -70,14 +72,14 @@ class BookController extends Controller
/**
* Show the form for creating a new book.
*/
public function create(string $shelfSlug = null)
public function create(?string $shelfSlug = null)
{
$this->checkPermission('book-create-all');
$this->checkPermission(Permission::BookCreateAll);
$bookshelf = null;
if ($shelfSlug !== null) {
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
$this->checkOwnablePermission(Permission::BookshelfUpdate, $bookshelf);
}
$this->setPageTitle(trans('entities.books_create'));
@@ -93,9 +95,9 @@ class BookController extends Controller
* @throws ImageUploadException
* @throws ValidationException
*/
public function store(Request $request, string $shelfSlug = null)
public function store(Request $request, ?string $shelfSlug = null)
{
$this->checkPermission('book-create-all');
$this->checkPermission(Permission::BookCreateAll);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
@@ -107,7 +109,7 @@ class BookController extends Controller
$bookshelf = null;
if ($shelfSlug !== null) {
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
$this->checkOwnablePermission(Permission::BookshelfUpdate, $bookshelf);
}
$book = $this->bookRepo->create($validated);
@@ -153,7 +155,7 @@ class BookController extends Controller
public function edit(string $slug)
{
$book = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('book-update', $book);
$this->checkOwnablePermission(Permission::BookUpdate, $book);
$this->setPageTitle(trans('entities.books_edit_named', ['bookName' => $book->getShortName()]));
return view('books.edit', ['book' => $book, 'current' => $book]);
@@ -169,7 +171,7 @@ class BookController extends Controller
public function update(Request $request, string $slug)
{
$book = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('book-update', $book);
$this->checkOwnablePermission(Permission::BookUpdate, $book);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
@@ -196,7 +198,7 @@ class BookController extends Controller
public function showDelete(string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-delete', $book);
$this->checkOwnablePermission(Permission::BookDelete, $book);
$this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()]));
return view('books.delete', ['book' => $book, 'current' => $book]);
@@ -210,7 +212,7 @@ class BookController extends Controller
public function destroy(string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-delete', $book);
$this->checkOwnablePermission(Permission::BookDelete, $book);
$this->bookRepo->destroy($book);
@@ -225,7 +227,7 @@ class BookController extends Controller
public function showCopy(string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-view', $book);
$this->checkOwnablePermission(Permission::BookView, $book);
session()->flashInput(['name' => $book->name]);
@@ -242,8 +244,8 @@ class BookController extends Controller
public function copy(Request $request, Cloner $cloner, string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-view', $book);
$this->checkPermission('book-create-all');
$this->checkOwnablePermission(Permission::BookView, $book);
$this->checkPermission(Permission::BookCreateAll);
$newName = $request->get('name') ?: $book->name;
$bookCopy = $cloner->cloneBook($book, $newName);
@@ -258,12 +260,14 @@ class BookController extends Controller
public function convertToShelf(HierarchyTransformer $transformer, string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$this->checkOwnablePermission('book-delete', $book);
$this->checkPermission('bookshelf-create-all');
$this->checkPermission('book-create-all');
$this->checkOwnablePermission(Permission::BookUpdate, $book);
$this->checkOwnablePermission(Permission::BookDelete, $book);
$this->checkPermission(Permission::BookshelfCreateAll);
$this->checkPermission(Permission::BookCreateAll);
$shelf = $transformer->transformBookToShelf($book);
$shelf = (new DatabaseTransaction(function () use ($book, $transformer) {
return $transformer->transformBookToShelf($book);
}))->run();
return redirect($shelf->getUrl());
}

View File

@@ -1,71 +0,0 @@
<?php
namespace BookStack\Entities\Controllers;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\BookSortMap;
use BookStack\Facades\Activity;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
class BookSortController extends Controller
{
public function __construct(
protected BookQueries $queries,
) {
}
/**
* Shows the view which allows pages to be re-ordered and sorted.
*/
public function show(string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$bookChildren = (new BookContents($book))->getTree(false);
$this->setPageTitle(trans('entities.books_sort_named', ['bookName' => $book->getShortName()]));
return view('books.sort', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]);
}
/**
* Shows the sort box for a single book.
* Used via AJAX when loading in extra books to a sort.
*/
public function showItem(string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$bookChildren = (new BookContents($book))->getTree();
return view('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
}
/**
* Sorts a book using a given mapping array.
*/
public function update(Request $request, string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-update', $book);
// Return if no map sent
if (!$request->filled('sort-tree')) {
return redirect($book->getUrl());
}
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
$bookContents = new BookContents($book);
$booksInvolved = $bookContents->sortUsingMap($sortMap);
// Rebuild permissions and add activity for involved books.
foreach ($booksInvolved as $bookInvolved) {
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
}
return redirect($book->getUrl());
}
}

View File

@@ -6,6 +6,7 @@ use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Request;
@@ -45,7 +46,7 @@ class BookshelfApiController extends ApiController
*/
public function create(Request $request)
{
$this->checkPermission('bookshelf-create-all');
$this->checkPermission(Permission::BookshelfCreateAll);
$requestData = $this->validate($request, $this->rules()['create']);
$bookIds = $request->get('books', []);
@@ -84,7 +85,7 @@ class BookshelfApiController extends ApiController
public function update(Request $request, string $id)
{
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('bookshelf-update', $shelf);
$this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf);
$requestData = $this->validate($request, $this->rules()['update']);
$bookIds = $request->get('books', null);
@@ -103,7 +104,7 @@ class BookshelfApiController extends ApiController
public function delete(string $id)
{
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->checkOwnablePermission(Permission::BookshelfDelete, $shelf);
$this->bookshelfRepo->destroy($shelf);
@@ -115,9 +116,10 @@ class BookshelfApiController extends ApiController
$shelf = clone $shelf;
$shelf->unsetRelations()->refresh();
$shelf->load(['tags', 'cover']);
$shelf->makeVisible('description_html')
->setAttribute('description_html', $shelf->descriptionHtml());
$shelf->load(['tags']);
$shelf->makeVisible(['cover', 'description_html'])
->setAttribute('description_html', $shelf->descriptionInfo()->getHtml())
->setAttribute('cover', $shelf->coverInfo()->getImage());
return $shelf;
}

View File

@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use BookStack\Util\SimpleListOptions;
use Exception;
@@ -68,7 +69,7 @@ class BookshelfController extends Controller
*/
public function create()
{
$this->checkPermission('bookshelf-create-all');
$this->checkPermission(Permission::BookshelfCreateAll);
$books = $this->bookQueries->visibleForList()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
$this->setPageTitle(trans('entities.shelves_create'));
@@ -83,7 +84,7 @@ class BookshelfController extends Controller
*/
public function store(Request $request)
{
$this->checkPermission('bookshelf-create-all');
$this->checkPermission(Permission::BookshelfCreateAll);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
@@ -105,7 +106,7 @@ class BookshelfController extends Controller
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-view', $shelf);
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
'default' => trans('common.sort_default'),
@@ -115,6 +116,7 @@ class BookshelfController extends Controller
]);
$sort = $listOptions->getSort();
$sortedVisibleShelfBooks = $shelf->visibleBooks()
->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder())
->get()
@@ -143,7 +145,7 @@ class BookshelfController extends Controller
public function edit(string $slug)
{
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-update', $shelf);
$this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf);
$shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
$books = $this->bookQueries->visibleForList()
@@ -169,7 +171,7 @@ class BookshelfController extends Controller
public function update(Request $request, string $slug)
{
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-update', $shelf);
$this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
@@ -195,7 +197,7 @@ class BookshelfController extends Controller
public function showDelete(string $slug)
{
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->checkOwnablePermission(Permission::BookshelfDelete, $shelf);
$this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()]));
@@ -210,7 +212,7 @@ class BookshelfController extends Controller
public function destroy(string $slug)
{
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->checkOwnablePermission(Permission::BookshelfDelete, $shelf);
$this->shelfRepo->destroy($shelf);

View File

@@ -2,19 +2,20 @@
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;
use BookStack\Permissions\Permission;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\Request;
class ChapterApiController extends ApiController
{
protected $rules = [
protected array $rules = [
'create' => [
'book_id' => ['required', 'integer'],
'name' => ['required', 'string', 'max:255'],
@@ -65,7 +66,7 @@ class ChapterApiController extends ApiController
$bookId = $request->get('book_id');
$book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId));
$this->checkOwnablePermission('chapter-create', $book);
$this->checkOwnablePermission(Permission::ChapterCreate, $book);
$chapter = $this->chapterRepo->create($requestData, $book);
@@ -101,10 +102,10 @@ class ChapterApiController extends ApiController
{
$requestData = $this->validate($request, $this->rules()['update']);
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
$this->checkOwnablePermission('chapter-delete', $chapter);
if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) {
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
try {
$this->chapterRepo->move($chapter, "book:{$requestData['book_id']}");
@@ -129,7 +130,7 @@ class ChapterApiController extends ApiController
public function delete(string $id)
{
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$this->chapterRepo->destroy($chapter);
@@ -143,8 +144,11 @@ class ChapterApiController extends ApiController
$chapter->load(['tags']);
$chapter->makeVisible('description_html');
$chapter->setAttribute('description_html', $chapter->descriptionHtml());
$chapter->setAttribute('book_slug', $chapter->book()->first()->slug);
$chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml());
/** @var Book $book */
$book = $chapter->book()->first();
$chapter->setAttribute('book_slug', $book->slug);
return $chapter;
}

View File

@@ -17,7 +17,9 @@ use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use BookStack\Util\DatabaseTransaction;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Throwable;
@@ -38,7 +40,7 @@ class ChapterController extends Controller
public function create(string $bookSlug)
{
$book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('chapter-create', $book);
$this->checkOwnablePermission(Permission::ChapterCreate, $book);
$this->setPageTitle(trans('entities.chapters_create'));
@@ -63,7 +65,7 @@ class ChapterController extends Controller
]);
$book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('chapter-create', $book);
$this->checkOwnablePermission(Permission::ChapterCreate, $book);
$chapter = $this->chapterRepo->create($validated, $book);
@@ -76,7 +78,6 @@ class ChapterController extends Controller
public function show(string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
$sidebarTree = (new BookContents($chapter->book))->getTree();
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
@@ -105,7 +106,7 @@ class ChapterController extends Controller
public function edit(string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()]));
@@ -127,9 +128,9 @@ class ChapterController extends Controller
]);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->chapterRepo->update($chapter, $validated);
$chapter = $this->chapterRepo->update($chapter, $validated);
return redirect($chapter->getUrl());
}
@@ -142,7 +143,7 @@ class ChapterController extends Controller
public function showDelete(string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()]));
@@ -158,7 +159,7 @@ class ChapterController extends Controller
public function destroy(string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$this->chapterRepo->destroy($chapter);
@@ -174,8 +175,8 @@ class ChapterController extends Controller
{
$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);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
return view('chapters.move', [
'chapter' => $chapter,
@@ -191,8 +192,8 @@ class ChapterController extends Controller
public function move(Request $request, string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
@@ -220,7 +221,6 @@ class ChapterController extends Controller
public function showCopy(string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
session()->flashInput(['name' => $chapter->name]);
@@ -239,7 +239,6 @@ class ChapterController extends Controller
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
$entitySelection = $request->get('entity_selection') ?: null;
$newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent();
@@ -250,7 +249,7 @@ class ChapterController extends Controller
return redirect($chapter->getUrl('/copy'));
}
$this->checkOwnablePermission('chapter-create', $newParentBook);
$this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook);
$newName = $request->get('name') ?: $chapter->name;
$chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
@@ -265,11 +264,13 @@ class ChapterController extends Controller
public function convertToBook(HierarchyTransformer $transformer, string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkPermission('book-create-all');
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$this->checkPermission(Permission::BookCreateAll);
$book = $transformer->transformChapterToBook($chapter);
$book = (new DatabaseTransaction(function () use ($chapter, $transformer) {
return $transformer->transformChapterToBook($chapter);
}))->run();
return redirect($book->getUrl());
}

View File

@@ -2,17 +2,19 @@
namespace BookStack\Entities\Controllers;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Exception;
use Illuminate\Http\Request;
class PageApiController extends ApiController
{
protected $rules = [
protected array $rules = [
'create' => [
'book_id' => ['required_without:chapter_id', 'integer'],
'chapter_id' => ['required_without:book_id', 'integer'],
@@ -76,7 +78,7 @@ class PageApiController extends ApiController
} else {
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
}
$this->checkOwnablePermission('page-create', $parent);
$this->checkOwnablePermission(Permission::PageCreate, $parent);
$draft = $this->pageRepo->getNewDraftPage($parent);
$this->pageRepo->publishDraft($draft, $request->only(array_keys($this->rules['create'])));
@@ -87,21 +89,32 @@ class PageApiController extends ApiController
/**
* View the details of a single page.
* Pages will always have HTML content. They may have markdown content
* if the markdown editor was used to last update the page.
* if the Markdown editor was used to last update the page.
*
* The 'html' property is the fully rendered & escaped HTML content that BookStack
* The 'html' property is the fully rendered and escaped HTML content that BookStack
* would show on page view, with page includes handled.
* The 'raw_html' property is the direct database stored HTML content, which would be
* what BookStack shows on page edit.
*
* See the "Content Security" section of these docs for security considerations when using
* the page content returned from this endpoint.
*
* Comments for the page are provided in a tree-structure representing the hierarchy of top-level
* comments and replies, for both archived and active comments.
*/
public function read(string $id)
{
$page = $this->queries->findVisibleByIdOrFail($id);
return response()->json($page->forJsonDisplay());
$page = $page->forJsonDisplay();
$commentTree = (new CommentTree($page));
$commentTree->loadVisibleHtml();
$page->setAttribute('comments', [
'active' => $commentTree->getActive(),
'archived' => $commentTree->getArchived(),
]);
return response()->json($page);
}
/**
@@ -116,7 +129,7 @@ class PageApiController extends ApiController
$requestData = $this->validate($request, $this->rules['update']);
$page = $this->queries->findVisibleByIdOrFail($id);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$parent = null;
if ($request->has('chapter_id')) {
@@ -126,7 +139,7 @@ class PageApiController extends ApiController
}
if ($parent && !$parent->matches($page->getParent())) {
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
try {
$this->pageRepo->move($page, $parent->getType() . ':' . $parent->id);
@@ -151,7 +164,7 @@ class PageApiController extends ApiController
public function delete(string $id)
{
$page = $this->queries->findVisibleByIdOrFail($id);
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
$this->pageRepo->destroy($page);

View File

@@ -17,8 +17,10 @@ use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditActivity;
use BookStack\Entities\Tools\PageEditorData;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -41,7 +43,7 @@ class PageController extends Controller
*
* @throws Throwable
*/
public function create(string $bookSlug, string $chapterSlug = null)
public function create(string $bookSlug, ?string $chapterSlug = null)
{
if ($chapterSlug) {
$parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
@@ -49,7 +51,7 @@ class PageController extends Controller
$parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
}
$this->checkOwnablePermission('page-create', $parent);
$this->checkOwnablePermission(Permission::PageCreate, $parent);
// Redirect to draft edit screen if signed in
if ($this->isSignedIn()) {
@@ -69,7 +71,7 @@ class PageController extends Controller
*
* @throws ValidationException
*/
public function createAsGuest(Request $request, string $bookSlug, string $chapterSlug = null)
public function createAsGuest(Request $request, string $bookSlug, ?string $chapterSlug = null)
{
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
@@ -81,7 +83,7 @@ class PageController extends Controller
$parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
}
$this->checkOwnablePermission('page-create', $parent);
$this->checkOwnablePermission(Permission::PageCreate, $parent);
$page = $this->pageRepo->getNewDraftPage($parent);
$this->pageRepo->publishDraft($page, [
@@ -99,7 +101,7 @@ class PageController extends Controller
public function editDraft(Request $request, string $bookSlug, int $pageId)
{
$draft = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-create', $draft->getParent());
$this->checkOwnablePermission(Permission::PageCreate, $draft->getParent());
$editorData = new PageEditorData($draft, $this->entityQueries, $request->query('editor', ''));
$this->setPageTitle(trans('entities.pages_edit_draft'));
@@ -118,8 +120,9 @@ class PageController extends Controller
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
]);
$draftPage = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-create', $draftPage->getParent());
$this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent());
$page = $this->pageRepo->publishDraft($draftPage, $request->all());
@@ -147,8 +150,6 @@ class PageController extends Controller
return redirect($page->getUrl());
}
$this->checkOwnablePermission('page-view', $page);
$pageContent = (new PageContent($page));
$page->html = $pageContent->render();
$pageNav = $pageContent->getNavigation($page->html);
@@ -196,7 +197,7 @@ class PageController extends Controller
public function edit(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page, $page->getUrl());
$editorData = new PageEditorData($page, $this->entityQueries, $request->query('editor', ''));
if ($editorData->getWarnings()) {
@@ -220,7 +221,7 @@ class PageController extends Controller
'name' => ['required', 'string', 'max:255'],
]);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$this->pageRepo->update($page, $request->all());
@@ -235,7 +236,7 @@ class PageController extends Controller
public function saveDraft(Request $request, int $pageId)
{
$page = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
if (!$this->isSignedIn()) {
return $this->jsonError(trans('errors.guests_cannot_save_drafts'), 500);
@@ -272,7 +273,7 @@ class PageController extends Controller
public function showDelete(string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
$this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()]));
$usedAsTemplate =
$this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
@@ -294,7 +295,7 @@ class PageController extends Controller
public function showDeleteDraft(string $bookSlug, int $pageId)
{
$page = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()]));
$usedAsTemplate =
$this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
@@ -317,7 +318,7 @@ class PageController extends Controller
public function destroy(string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
$parent = $page->getParent();
$this->pageRepo->destroy($page);
@@ -336,13 +337,13 @@ class PageController extends Controller
$page = $this->queries->findVisibleByIdOrFail($pageId);
$book = $page->book;
$chapter = $page->chapter;
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$this->pageRepo->destroy($page);
$this->showSuccessNotification(trans('entities.pages_delete_draft_success'));
if ($chapter && userCan('view', $chapter)) {
if ($chapter && userCan(Permission::ChapterView, $chapter)) {
return redirect($chapter->getUrl());
}
@@ -383,8 +384,8 @@ class PageController extends Controller
public function showMove(string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
return view('pages.move', [
'book' => $page->book,
@@ -401,8 +402,8 @@ class PageController extends Controller
public function move(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
$entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
@@ -430,7 +431,6 @@ class PageController extends Controller
public function showCopy(string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-view', $page);
session()->flashInput(['name' => $page->name]);
return view('pages.copy', [
@@ -448,7 +448,7 @@ class PageController extends Controller
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-view', $page);
$this->checkOwnablePermission(Permission::PageView, $page);
$entitySelection = $request->get('entity_selection') ?: null;
$newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent();
@@ -459,7 +459,7 @@ class PageController extends Controller
return redirect($page->getUrl('/copy'));
}
$this->checkOwnablePermission('page-create', $newParent);
$this->checkOwnablePermission(Permission::PageCreate, $newParent);
$newName = $request->get('name') ?: $page->name;
$pageCopy = $cloner->clonePage($page, $newParent, $newName);

View File

@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\PageContent;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
use Ssddanbrown\HtmlDiff\Diff;
@@ -43,7 +44,6 @@ class PageRevisionController extends Controller
->selectRaw("IF(markdown = '', false, true) as is_markdown")
->with(['page.book', 'createdBy'])
->reorder('id', $listOptions->getOrder())
->reorder('created_at', $listOptions->getOrder())
->paginate(50);
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName' => $page->getShortName()]));
@@ -52,6 +52,7 @@ class PageRevisionController extends Controller
'revisions' => $revisions,
'page' => $page,
'listOptions' => $listOptions,
'oldestRevisionId' => $page->revisions()->min('id'),
]);
}
@@ -98,7 +99,7 @@ class PageRevisionController extends Controller
throw new NotFoundException();
}
$prev = $revision->getPrevious();
$prev = $revision->getPreviousRevision();
$prevContent = $prev->html ?? '';
$diff = Diff::excecute($prevContent, $revision->html);
@@ -124,7 +125,7 @@ class PageRevisionController extends Controller
public function restore(string $bookSlug, string $pageSlug, int $revisionId)
{
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$page = $this->pageRepo->restoreRevision($page, $revisionId);
@@ -139,7 +140,7 @@ class PageRevisionController extends Controller
public function destroy(string $bookSlug, string $pageSlug, int $revId)
{
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
$revision = $page->revisions()->where('id', '=', $revId)->first();
if ($revision === null) {

View File

@@ -6,18 +6,20 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\DeletionRepo;
use BookStack\Http\ApiController;
use Closure;
use BookStack\Permissions\Permission;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
class RecycleBinApiController extends ApiController
{
public function __construct()
{
$this->middleware(function ($request, $next) {
$this->checkPermission('settings-manage');
$this->checkPermission('restrictions-manage-all');
$this->checkPermission(Permission::SettingsManage);
$this->checkPermission(Permission::RestrictionsManageAll);
return $next($request);
});
@@ -40,7 +42,7 @@ class RecycleBinApiController extends ApiController
'updated_at',
'deletable_type',
'deletable_id',
], [Closure::fromCallable([$this, 'listFormatter'])]);
], [$this->listFormatter(...)]);
}
/**
@@ -69,10 +71,9 @@ class RecycleBinApiController extends ApiController
/**
* Load some related details for the deletion listing.
*/
protected function listFormatter(Deletion $deletion)
protected function listFormatter(Deletion $deletion): void
{
$deletable = $deletion->deletable;
$withTrashedQuery = fn (Builder $query) => $query->withTrashed();
if ($deletable instanceof BookChild) {
$parent = $deletable->getParent();
@@ -81,11 +82,19 @@ class RecycleBinApiController extends ApiController
}
if ($deletable instanceof Book || $deletable instanceof Chapter) {
$countsToLoad = ['pages' => $withTrashedQuery];
$countsToLoad = ['pages' => static::withTrashedQuery(...)];
if ($deletable instanceof Book) {
$countsToLoad['chapters'] = $withTrashedQuery;
$countsToLoad['chapters'] = static::withTrashedQuery(...);
}
$deletable->loadCount($countsToLoad);
}
}
/**
* @param Builder<Chapter|Page> $query
*/
protected static function withTrashedQuery(Builder $query): void
{
$query->withTrashed();
}
}

View File

@@ -8,6 +8,7 @@ use BookStack\Entities\Models\Entity;
use BookStack\Entities\Repos\DeletionRepo;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
class RecycleBinController extends Controller
{
@@ -20,8 +21,8 @@ class RecycleBinController extends Controller
public function __construct()
{
$this->middleware(function ($request, $next) {
$this->checkPermission('settings-manage');
$this->checkPermission('restrictions-manage-all');
$this->checkPermission(Permission::SettingsManage);
$this->checkPermission(Permission::RestrictionsManageAll);
return $next($request);
});

View File

@@ -0,0 +1,20 @@
<?php
namespace BookStack\Entities;
use Illuminate\Validation\Rules\Exists;
class EntityExistsRule implements \Stringable
{
public function __construct(
protected string $type,
) {
}
public function __toString()
{
$existsRule = (new Exists('entities', 'id'))
->where('type', $this->type);
return $existsRule->__toString();
}
}

View File

@@ -2,8 +2,10 @@
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityCover;
use BookStack\Entities\Tools\EntityDefaultTemplate;
use BookStack\Sorting\SortRule;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -14,24 +16,25 @@ use Illuminate\Support\Collection;
* Class Book.
*
* @property string $description
* @property string $description_html
* @property int $image_id
* @property ?int $default_template_id
* @property Image|null $cover
* @property ?int $sort_rule_id
* @property \Illuminate\Database\Eloquent\Collection $chapters
* @property \Illuminate\Database\Eloquent\Collection $pages
* @property \Illuminate\Database\Eloquent\Collection $directPages
* @property \Illuminate\Database\Eloquent\Collection $shelves
* @property ?Page $defaultTemplate
* @property ?SortRule $sortRule
*/
class Book extends Entity implements HasCoverImage
class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface
{
use HasFactory;
use HasHtmlDescription;
use ContainerTrait;
public float $searchFactor = 1.2;
protected $hidden = ['pivot', 'deleted_at', 'description_html', 'entity_id', 'entity_type', 'chapter_id', 'book_id', 'priority'];
protected $fillable = ['name'];
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html'];
/**
* Get the url for this book.
@@ -41,49 +44,9 @@ class Book extends Entity implements HasCoverImage
return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
}
/**
* Returns book cover image, if book cover not exists return default cover image.
*/
public function getBookCover(int $width = 440, int $height = 250): string
{
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
if (!$this->image_id || !$this->cover) {
return $default;
}
try {
return $this->cover->getThumb($width, $height, false) ?? $default;
} catch (Exception $err) {
return $default;
}
}
/**
* Get the cover image of the book.
*/
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
/**
* Get the type of the image model that is used when storing a cover image.
*/
public function coverImageTypeKey(): string
{
return 'cover_book';
}
/**
* Get the Page that is used as default template for newly created pages within this Book.
*/
public function defaultTemplate(): BelongsTo
{
return $this->belongsTo(Page::class, 'default_template_id');
}
/**
* Get all pages within this book.
* @return HasMany<Page, $this>
*/
public function pages(): HasMany
{
@@ -95,11 +58,12 @@ class Book extends Entity implements HasCoverImage
*/
public function directPages(): HasMany
{
return $this->pages()->where('chapter_id', '=', '0');
return $this->pages()->whereNull('chapter_id');
}
/**
* Get all chapters within this book.
* @return HasMany<Chapter, $this>
*/
public function chapters(): HasMany
{
@@ -124,4 +88,27 @@ class Book extends Entity implements HasCoverImage
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
}
public function defaultTemplate(): EntityDefaultTemplate
{
return new EntityDefaultTemplate($this);
}
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
public function coverInfo(): EntityCover
{
return new EntityCover($this);
}
/**
* Get the sort rule assigned to this container, if existing.
*/
public function sortRule(): BelongsTo
{
return $this->belongsTo(SortRule::class);
}
}

View File

@@ -3,7 +3,6 @@
namespace BookStack\Entities\Models;
use BookStack\References\ReferenceUpdater;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
@@ -27,13 +26,13 @@ abstract class BookChild extends Entity
/**
* Change the book that this entity belongs to.
*/
public function changeBook(int $newBookId): Entity
public function changeBook(int $newBookId): self
{
$oldUrl = $this->getUrl();
$this->book_id = $newBookId;
$this->unsetRelation('book');
$this->refreshSlug();
$this->save();
$this->refresh();
if ($oldUrl !== $this->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);

View File

@@ -2,34 +2,34 @@
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityCover;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Bookshelf extends Entity implements HasCoverImage
/**
* @property string $description
* @property string $description_html
*/
class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInterface
{
use HasFactory;
use HasHtmlDescription;
protected $table = 'bookshelves';
use ContainerTrait;
public float $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'image_id'];
protected $hidden = ['image_id', 'deleted_at', 'description_html'];
protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
protected $fillable = ['name'];
/**
* Get the books in this shelf.
* Should not be used directly since does not take into account permissions.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
* Should not be used directly since it does not take into account permissions.
*/
public function books()
public function books(): BelongsToMany
{
return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id')
->select(['entities.*', 'entity_container_data.*'])
->withPivot('order')
->orderBy('order', 'asc');
}
@@ -50,40 +50,6 @@ class Bookshelf extends Entity implements HasCoverImage
return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
}
/**
* Returns shelf cover image, if cover not exists return default cover image.
*/
public function getBookCover(int $width = 440, int $height = 250): string
{
// TODO - Make generic, focused on books right now, Perhaps set-up a better image
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
if (!$this->image_id || !$this->cover) {
return $default;
}
try {
return $this->cover->getThumb($width, $height, false) ?? $default;
} catch (Exception $err) {
return $default;
}
}
/**
* Get the cover image of the shelf.
*/
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
/**
* Get the type of the image model that is used when storing a cover image.
*/
public function coverImageTypeKey(): string
{
return 'cover_bookshelf';
}
/**
* Check if this shelf contains the given book.
*/
@@ -95,7 +61,7 @@ class Bookshelf extends Entity implements HasCoverImage
/**
* Add a book to the end of this shelf.
*/
public function appendBook(Book $book)
public function appendBook(Book $book): void
{
if ($this->contains($book)) {
return;
@@ -105,12 +71,13 @@ class Bookshelf extends Entity implements HasCoverImage
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
}
/**
* Get a visible shelf by its slug.
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function getBySlug(string $slug): self
public function coverInfo(): EntityCover
{
return static::visible()->where('slug', '=', $slug)->firstOrFail();
return new EntityCover($this);
}
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
}

View File

@@ -2,32 +2,30 @@
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use BookStack\Entities\Tools\EntityDefaultTemplate;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
/**
* Class Chapter.
*
* @property Collection<Page> $pages
* @property ?int $default_template_id
* @property ?Page $defaultTemplate
* @property string $description
* @property string $description_html
*/
class Chapter extends BookChild
class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface
{
use HasFactory;
use HasHtmlDescription;
use ContainerTrait;
public float $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'priority'];
protected $hidden = ['pivot', 'deleted_at', 'description_html'];
protected $hidden = ['pivot', 'deleted_at', 'description_html', 'sort_rule_id', 'image_id', 'entity_id', 'entity_type', 'chapter_id'];
protected $fillable = ['name', 'priority'];
/**
* Get the pages that this chapter contains.
*
* @return HasMany<Page>
* @return HasMany<Page, $this>
*/
public function pages(string $dir = 'ASC'): HasMany
{
@@ -50,17 +48,9 @@ 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.
* @returns Collection<Page>
* @return Collection<Page>
*/
public function getVisiblePages(): Collection
{
@@ -70,4 +60,9 @@ class Chapter extends BookChild
->orderBy('priority', 'asc')
->get();
}
public function defaultTemplate(): EntityDefaultTemplate
{
return new EntityDefaultTemplate($this);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityHtmlDescription;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* @mixin Entity
*/
trait ContainerTrait
{
public function descriptionInfo(): EntityHtmlDescription
{
return new EntityHtmlDescription($this);
}
/**
* @return HasOne<EntityContainerData, $this>
*/
public function relatedData(): HasOne
{
return $this->hasOne(EntityContainerData::class, 'entity_id', 'id')
->where('entity_type', '=', $this->getMorphClass());
}
}

View File

@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
* A model that can be deleted in a manner that deletions
* are tracked to be part of the recycle bin system.
*/
interface Deletable
interface DeletableInterface
{
public function deletions(): MorphMany;
}

View File

@@ -4,6 +4,7 @@ namespace BookStack\Entities\Models;
use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -13,10 +14,12 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
* @property int $deleted_by
* @property string $deletable_type
* @property int $deletable_id
* @property Deletable $deletable
* @property DeletableInterface $deletable
*/
class Deletion extends Model implements Loggable
{
use HasFactory;
protected $hidden = [];
/**

View File

@@ -12,7 +12,7 @@ use BookStack\Activity\Models\View;
use BookStack\Activity\Models\Viewable;
use BookStack\Activity\Models\Watch;
use BookStack\App\Model;
use BookStack\App\Sluggable;
use BookStack\App\SluggableInterface;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Permissions\JointPermissionBuilder;
use BookStack\Permissions\Models\EntityPermission;
@@ -22,37 +22,47 @@ use BookStack\References\Reference;
use BookStack\Search\SearchIndex;
use BookStack\Search\SearchTerm;
use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Users\Models\HasOwner;
use BookStack\Users\Models\OwnableInterface;
use BookStack\Users\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Class Entity
* The base class for book-like items such as pages, chapters & books.
* The base class for book-like items such as pages, chapters and books.
* This is not a database model in itself but extended.
*
* @property int $id
* @property string $type
* @property string $name
* @property string $slug
* @property Carbon $created_at
* @property Carbon $updated_at
* @property Carbon $deleted_at
* @property int $created_by
* @property int $updated_by
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $owned_by
* @property Collection $tags
*
* @method static Entity|Builder visible()
* @method static Builder withLastView()
* @method static Builder withViewCount()
*/
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable
abstract class Entity extends Model implements
SluggableInterface,
Favouritable,
Viewable,
DeletableInterface,
OwnableInterface,
Loggable
{
use SoftDeletes;
use HasCreatorAndUpdater;
use HasOwner;
/**
* @var string - Name of property where the main text content is found
@@ -69,6 +79,72 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public float $searchFactor = 1.0;
/**
* Set the table to be that used by all entities.
*/
protected $table = 'entities';
/**
* Set a custom query builder for entities.
*/
protected static string $builder = EntityQueryBuilder::class;
public static array $commonFields = [
'id',
'type',
'name',
'slug',
'book_id',
'chapter_id',
'priority',
'created_at',
'updated_at',
'deleted_at',
'created_by',
'updated_by',
'owned_by',
];
/**
* Override the save method to also save the contents for convenience.
*/
public function save(array $options = []): bool
{
/** @var EntityPageData|EntityContainerData $contents */
$contents = $this->relatedData()->firstOrNew();
$contentFields = $this->getContentsAttributes();
foreach ($contentFields as $key => $value) {
$contents->setAttribute($key, $value);
unset($this->attributes[$key]);
}
$this->setAttribute('type', $this->getMorphClass());
$result = parent::save($options);
$contentsResult = true;
if ($result && $contents->isDirty()) {
$contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()];
$contents->forceFill($contentsFillData);
$contentsResult = $contents->save();
$this->touch();
}
$this->forceFill($contentFields);
return $result && $contentsResult;
}
/**
* Check if this item is a container item.
*/
public function isContainer(): bool
{
return $this instanceof Bookshelf ||
$this instanceof Book ||
$this instanceof Chapter;
}
/**
* Get the entities that are visible to the current user.
*/
@@ -83,8 +159,8 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
public function scopeWithLastView(Builder $query)
{
$viewedAtQuery = View::query()->select('updated_at')
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
->where('viewable_type', '=', $this->getMorphClass())
->whereColumn('viewable_id', '=', 'entities.id')
->whereColumn('viewable_type', '=', 'entities.type')
->where('user_id', '=', user()->id)
->take(1);
@@ -94,11 +170,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/**
* Query scope to get the total view count of the entities.
*/
public function scopeWithViewCount(Builder $query)
public function scopeWithViewCount(Builder $query): void
{
$viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
->where('viewable_type', '=', $this->getMorphClass())->take(1);
->whereColumn('viewable_id', '=', 'entities.id')
->whereColumn('viewable_type', '=', 'entities.type')
->take(1);
$query->addSelect(['view_count' => $viewCountQuery]);
}
@@ -154,15 +231,17 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function tags(): MorphMany
{
return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
return $this->morphMany(Tag::class, 'entity')
->orderBy('order', 'asc');
}
/**
* Get the comments for an entity.
* @return MorphMany<Comment, $this>
*/
public function comments(bool $orderByCreated = true): MorphMany
{
$query = $this->morphMany(Comment::class, 'entity');
$query = $this->morphMany(Comment::class, 'commentable');
return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
}
@@ -176,7 +255,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
}
/**
* Get this entities restrictions.
* Get this entities assigned permissions.
*/
public function permissions(): MorphMany
{
@@ -199,6 +278,20 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
return $this->morphMany(JointPermission::class, 'entity');
}
/**
* Get the user who owns this entity.
* @return BelongsTo<User, $this>
*/
public function ownedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'owned_by');
}
public function getOwnerFieldName(): string
{
return 'owned_by';
}
/**
* Get the related delete records for this entity.
*/
@@ -245,7 +338,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
}
/**
* Gets a limited-length version of the entities name.
* Gets a limited-length version of the entity name.
*/
public function getShortName(int $length = 25): string
{
@@ -283,10 +376,14 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
public function getParent(): ?self
{
if ($this instanceof Page) {
return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first();
/** @var BelongsTo<Chapter|Book, Page> $builder */
$builder = $this->chapter_id ? $this->chapter() : $this->book();
return $builder->withTrashed()->first();
}
if ($this instanceof Chapter) {
return $this->book()->withTrashed()->first();
/** @var BelongsTo<Book, Page> $builder */
$builder = $this->book();
return $builder->withTrashed()->first();
}
return null;
@@ -295,7 +392,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/**
* Rebuild the permissions for this entity.
*/
public function rebuildPermissions()
public function rebuildPermissions(): void
{
app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this);
}
@@ -303,7 +400,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/**
* Index the current entity for search.
*/
public function indexForSearch()
public function indexForSearch(): void
{
app()->make(SearchIndex::class)->indexEntity(clone $this);
}
@@ -313,7 +410,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function refreshSlug(): string
{
$this->slug = app()->make(SlugGenerator::class)->generate($this);
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
return $this->slug;
}
@@ -351,4 +448,40 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
{
return "({$this->id}) {$this->name}";
}
/**
* @return HasOne<covariant (EntityContainerData|EntityPageData), $this>
*/
abstract public function relatedData(): HasOne;
/**
* Get the attributes that are intended for the related contents model.
* @return array<string, mixed>
*/
protected function getContentsAttributes(): array
{
$contentFields = [];
$contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class;
foreach ($this->attributes as $key => $value) {
if (in_array($key, $contentModel::$fields)) {
$contentFields[$key] = $value;
}
}
return $contentFields;
}
/**
* Create a new instance for the given entity type.
*/
public static function instanceFromType(string $type): self
{
return match ($type) {
'page' => new Page(),
'chapter' => new Chapter(),
'book' => new Book(),
'bookshelf' => new Bookshelf(),
};
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $entity_id
* @property string $entity_type
* @property string $description
* @property string $description_html
* @property ?int $default_template_id
* @property ?int $image_id
* @property ?int $sort_rule_id
*/
class EntityContainerData extends Model
{
public $timestamps = false;
protected $primaryKey = 'entity_id';
public $incrementing = false;
public static array $fields = [
'description',
'description_html',
'default_template_id',
'image_id',
'sort_rule_id',
];
/**
* Override the default set keys for save query method to make it work with composite keys.
*/
public function setKeysForSaveQuery($query): Builder
{
$query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery())
->where('entity_type', '=', $this->entity_type);
return $query;
}
/**
* Override the default set keys for a select query method to make it work with composite keys.
*/
protected function setKeysForSelectQuery($query): Builder
{
$query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery())
->where('entity_type', '=', $this->entity_type);
return $query;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $page_id
*/
class EntityPageData extends Model
{
public $timestamps = false;
protected $primaryKey = 'page_id';
public $incrementing = false;
public static array $fields = [
'draft',
'template',
'revision_count',
'editor',
'html',
'text',
'markdown',
];
}

View File

@@ -0,0 +1,38 @@
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
class EntityQueryBuilder extends Builder
{
/**
* Create a new Eloquent query builder instance.
*/
public function __construct(QueryBuilder $query)
{
parent::__construct($query);
$this->withGlobalScope('entity', new EntityScope());
}
public function withoutGlobalScope($scope): static
{
// Prevent removal of the entity scope
if ($scope === 'entity') {
return $this;
}
return parent::withoutGlobalScope($scope);
}
/**
* Override the default forceDelete method to add type filter onto the query
* since it specifically ignores scopes by default.
*/
public function forceDelete()
{
return $this->query->where('type', '=', $this->model->getMorphClass())->delete();
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Database\Query\JoinClause;
class EntityScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
*/
public function apply(Builder $builder, Model $model): void
{
$builder = $builder->where('type', '=', $model->getMorphClass());
$table = $model->getTable();
if ($model instanceof Page) {
$builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', "{$table}.id");
} else {
$builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model, $table) {
$join->on('entity_container_data.entity_id', '=', "{$table}.id")
->where('entity_container_data.entity_type', '=', $model->getMorphClass());
});
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Activity\Models\Tag;
use BookStack\Activity\Models\View;
use BookStack\App\Model;
use BookStack\Permissions\Models\EntityPermission;
use BookStack\Permissions\Models\JointPermission;
use BookStack\Permissions\PermissionApplicator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* This is a simplistic model interpretation of a generic Entity used to query and represent
* that database abstractly. Generally, this should rarely be used outside queries.
*/
class EntityTable extends Model
{
use SoftDeletes;
protected $table = 'entities';
/**
* Get the entities that are visible to the current user.
*/
public function scopeVisible(Builder $query): Builder
{
return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
}
/**
* Get the entity jointPermissions this is connected to.
*/
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id')
->whereColumn('entity_type', '=', 'entities.type');
}
/**
* Get the Tags that have been assigned to entities.
*/
public function tags(): HasMany
{
return $this->hasMany(Tag::class, 'entity_id')
->whereColumn('entity_type', '=', 'entities.type');
}
/**
* Get the assigned permissions.
*/
public function permissions(): HasMany
{
return $this->hasMany(EntityPermission::class, 'entity_id')
->whereColumn('entity_type', '=', 'entities.type');
}
/**
* Get View objects for this entity.
*/
public function views(): HasMany
{
return $this->hasMany(View::class, 'viewable_id')
->whereColumn('viewable_type', '=', 'entities.type');
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
interface HasCoverImage
{
/**
* Get the cover image for this item.
*/
public function cover(): BelongsTo;
/**
* Get the type of the image model that is used when storing a cover image.
*/
public function coverImageTypeKey(): string;
}

View File

@@ -0,0 +1,18 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityCover;
use BookStack\Uploads\Image;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
interface HasCoverInterface
{
public function coverInfo(): EntityCover;
/**
* The cover image of this entity.
* @return BelongsTo<Image, covariant Entity>
*/
public function cover(): BelongsTo;
}

View File

@@ -0,0 +1,10 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityDefaultTemplate;
interface HasDefaultTemplateInterface
{
public function defaultTemplate(): EntityDefaultTemplate;
}

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