Compare commits

...

388 Commits

Author SHA1 Message Date
Dan Brown
ed4baed28c Updated version and assets for release v25.12.2 2026-01-24 13:57:12 +00:00
Dan Brown
90d011fc15 Merge branch 'development' into release 2026-01-24 13:54:51 +00:00
Dan Brown
ff59bbdc07 Updated translator & dependency attribution before release v25.12.2 2026-01-24 13:53:55 +00:00
Dan Brown
4dc443b7df Updated translations with latest Crowdin changes (#5970) 2026-01-22 17:53:58 +00:00
Dan Brown
19f02d927e Deps: Updated PHP package versions 2026-01-22 17:39:26 +00:00
Dan Brown
da7bedd2e4 Sponsors: Added Onyx 2026-01-13 13:23:54 +00:00
Dan Brown
20db372596 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2026-01-07 11:10:55 +00:00
Dan Brown
43eed1660c Meta: Updated dev version, license year, crowdin config
Added Id to crowdin config for compatibility with upcoming change to
crowdin CLI process after switch to codeberg
2026-01-07 11:09:39 +00:00
Dan Brown
e6b754fad0 Merge pull request #5969 from shaoliang123456/fix/git-safe-directory-in-docker
Git 2.35+ may refuse to operate on bind-mounted repos with differing ownership ("dubious ownership"), Mark /app as safe within the container.
2026-01-03 17:56:52 +00:00
leon
018de5def3 fix: Configure safe directory for git in dockerfile 2025-12-31 16:20:52 +08:00
leon
5c4fc3dc2c fix: Docker: Add
git safe.directory config for bind-mounted repos.Mark
 /app as safe directory to handle Git 2.35+ ownership
 checks in Docker containers.
2025-12-31 11:53:22 +08:00
Dan Brown
805fd98c0f Updated version and assets for release v25.12.1 2025-12-30 17:19:10 +00:00
Dan Brown
fcbae16730 Merge branch 'development' into release 2025-12-30 17:18:06 +00:00
Dan Brown
07ec880e33 Testing: Updated search tests to consider new limits 2025-12-30 17:09:26 +00:00
Dan Brown
ab436ed5c3 Updated translations with latest Crowdin changes (#5962) 2025-12-30 16:32:21 +00:00
Dan Brown
082befb2fc Updated PHP packages and translators pre v25.12.1 2025-12-30 16:16:39 +00:00
Dan Brown
b0a8cb0c5d Merge pull request #5968 from BookStackApp/limits
Add some additional resource-based limits
2025-12-30 16:14:04 +00:00
Dan Brown
b08d1b36de Search: Set limits on the amount of search terms
Sets some reasonable limits, which are higher when logged in since that
infers a little extra trust.
Helps prevent against large resource consuption attacks via super heavy
search queries.

Thanks to Gabriel Rodrigues AKA TEXUGO for reporting.
2025-12-30 13:32:14 +00:00
Dan Brown
88d86df66f ZIP Exports: Added limit to ZIP file size before extraction
Checks files within the ZIP again the app upload file limit
before using/streaming/extracting, to help ensure that they do no exceed
what might be expected on that instance, and to prevent disk exhaustion
via things like super high compression ratio files.

Thanks to Jeong Woo Lee (eclipse07077-ljw) for reporting.
2025-12-29 23:08:18 +00:00
Dan Brown
7c3a4c7e85 Updated version and assets for release v25.12 2025-12-24 12:16:49 +00:00
Dan Brown
114fa802c0 Merge branch 'development' into release 2025-12-24 12:16:04 +00:00
Dan Brown
38d3697246 Updated translations with latest Crowdin changes (#5933) 2025-12-24 11:52:56 +00:00
Dan Brown
d93354ff0e Updated licenses and translation attribution pre v25.12 2025-12-24 11:51:37 +00:00
Dan Brown
3336e0c6ae Deps: Updated PHP packages via composer 2025-12-24 11:48:42 +00:00
Dan Brown
8fc9a2af4e Lexical API: Updated docs to reflect public event usage 2025-12-23 18:33:54 +00:00
Dan Brown
8aec571123 Mentions: Fixed some users not showing in mention selector 2025-12-21 18:33:50 +00:00
Dan Brown
382f4db276 Merge pull request #5956 from BookStackApp/system_cli_update_dec25
System CLI: Update to v0.4
2025-12-20 15:48:59 +00:00
Dan Brown
d504b19143 System CLI: Update to v0.4
- The init & update commands will now use download-vendor logic instead
  of using composer to install required PHP packages.
- The init command will now use our source.bookstackapp.com git mirror
  instead of GitHub.
- Updated depenancy PHP package versions.
2025-12-20 15:46:15 +00:00
Dan Brown
d87e8d05c7 Merge pull request #5939 from BookStackApp/lexical_fixes_2512
Lexical fixes for v25.12
2025-12-20 14:05:30 +00:00
Dan Brown
0b48361780 Search: Fixed pagination not considering sub-paths
For #5951
Added test to cover.
2025-12-19 15:15:23 +00:00
Dan Brown
2de3247ae4 Deps: Updated PHP package versions
Includes major version change of antonioribeiro/google2fa which changes
secret length. From manual testing of old MFA secrets and new, this
should not be breaking at all.
2025-12-19 14:22:27 +00:00
Dan Brown
48df2be0d8 DB: Added views->viewable_type index
For #5948
2025-12-19 10:54:09 +00:00
Dan Brown
a4c0556551 Merge pull request #5952 from gerundt/iis-module-link
Update "Microsoft URL Rewrite Module for IIS" download link
2025-12-19 10:24:22 +00:00
Dan Brown
a941d1b403 Comment mentions: Fixed CI and test scenarios 2025-12-18 17:40:05 +00:00
Dan Brown
51f9b63db0 Comment Mentions: Fixed and tweaks during review of changes
- Added advisory on role permission form to advise which allow listing
  of users/roles.
- Updated database config to avoid PHP8.5 deprecation.
- Tweaked migration to remove unused index.
- Fixed test namespace.
2025-12-18 17:15:29 +00:00
Dan Brown
90fc02c57f Esbuild & Mentions: Updated interaction stability and build system
- Updated esbuild system to be module, and fixed build command.
- Reverted module use in package.json by default as this impacted test
  runs/files.
- Updated mention user select:
  - To look better in dark mode.
  - To not remove text after on select.
  - To properly revert/restore focus on enter or cancel.
2025-12-17 21:11:01 +00:00
Tim Gerundt
4aeb571126 Update "Microsoft URL Rewrite Module for IIS" download link 2025-12-17 15:11:29 +01:00
Dan Brown
3d9aba7b1f Mentions: Added coverage for mentions search endpoint 2025-12-17 13:48:20 +00:00
Dan Brown
48cdaab690 Comment Mentions: Added tests to cover back-end functionality 2025-12-17 10:49:12 +00:00
Dan Brown
4f760479c3 Merge branch 'development' into comment_mentions 2025-12-17 09:58:27 +00:00
Dan Brown
9211062e8e Merge pull request #5919 from BookStackApp/v25-11
Merge v25-11 changes into dev
2025-12-17 09:57:49 +00:00
Dan Brown
221c6c7e9f Comment Mentions: Added core back-end logic
- Added new user notification preference, opt-in by default
- Added parser to extract mentions from comment HTML, with tests to
  cover.
- Added notification and notification handling

Not yet tested, needs testing coverage.
2025-12-17 09:57:14 +00:00
Dan Brown
e2f91c2bbb Comment Mentions: Added keyboard nav, worked on design 2025-12-14 17:19:08 +00:00
Dan Brown
147ff00c7a JS: Swapped livereload lib for esbuild livereload setup 2025-12-14 16:25:19 +00:00
Dan Brown
1e768ce33f Lexical: Changed mention to be a decorator node
Allows better selection.
Also updated existing decorator file names to align with classes so
they're easier to find.
Also aligned/fixed decorator constuctor/setup methods.
2025-12-13 17:03:48 +00:00
Dan Brown
8fcd3b24b3 Updated version and assets for release v25.11.6 2025-12-09 21:01:26 +00:00
Dan Brown
ce703403c2 Merge branch 'v25-11' into release 2025-12-09 21:00:25 +00:00
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
9bf9ae9c37 Mentions: Added new endpoint, Built editor list display 2025-12-09 16:56:34 +00:00
Dan Brown
50540e23a1 Lexical: Created mention node, started mention service, split comment editor out 2025-12-08 15:52:21 +00:00
Dan Brown
3e1b0587ec Lexical: Fixed undefined entity selector value
Also added pre-fill of selector search based on selected text range.
2025-12-07 15:07:08 +00:00
Dan Brown
6661ae8178 Lexical: Improved focus control for popup modal forms
Now moves focus to first field on open, and restores focus back to
editor on submit/close.
2025-12-07 15:02:06 +00:00
Dan Brown
1ee5711435 Pagination: Fixed lack of responsiveness causing overflow
For #5920
2025-12-07 13:09:50 +00:00
Dan Brown
08e7ba7064 Images: Made resize errors log with error detail
Closes #5869
2025-12-07 12:54:57 +00:00
Dan Brown
34e747162f Testing: Fixed some incorrect test case names 2025-12-06 23:24:33 +00:00
Dan Brown
10f5ceee35 List page settings: Review of #5606
Updated setting display to show mulitple number inputs under one heading
group.
Updated settings to use general number field form view template.
Updated translations to match display changes, and to advise on counts.
Added page count control for search results.
Added setting service method, to get settings as integers, with
min/max/default control.
Updating sorting group to be names "Lists & Sorting".
Added tests to cover.
2025-12-06 23:10:54 +00:00
Dan Brown
9886bbd3a0 Merge branch 'feature/pagination_settings' of github.com:Xenoamor/BookStack into Xenoamor-feature/pagination_settings 2025-12-06 21:47:13 +00:00
Dan Brown
92a3c22b4c Merge pull request #5928 from BookStackApp/lexical_dev_api
Initial developer API for WYSIWYG editor
2025-12-06 15:32:47 +00:00
Dan Brown
b5246a28f0 Lexical API: Updated docs to align method format 2025-12-06 15:18:28 +00:00
Dan Brown
ab4b1c8efa Lexical API: Reviewed docs, Made toolbar its own UI class 2025-12-05 14:44:19 +00:00
Dan Brown
8890746278 Lexical API: Added public event to access editor API
Updated documentation to match.
Ran manual testing of examples.
2025-12-05 14:07:24 +00:00
Dan Brown
dfdcfcfdb8 Lexical API: Added content module, testing and documented 2025-12-05 12:15:18 +00:00
Dan Brown
ebceba0afe Lexical API: Started working on docs format and jest testing 2025-12-04 21:13:17 +00:00
Dan Brown
16110273ff Updated version and assets for release v25.11.5 2025-12-03 14:49:20 +00:00
Dan Brown
93bcbd168e Merge branch 'v25-11' into release 2025-12-03 14:32:56 +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
21730aeb39 Sponsors: Tweaked logo sizing 2025-12-02 18:48:21 +00:00
Dan Brown
75231d2d4a Sponsors: Added Admin Intelligence 2025-12-02 18:46:36 +00:00
Dan Brown
9d732d8dd8 Lexical: Started dev API outline 2025-11-30 17:02:17 +00:00
Dan Brown
9e8088f186 JS: Bumped up JS package versions, and node version 2025-11-30 15:07:23 +00:00
Dan Brown
cf847974d2 Merge pull request #5917 from BookStackApp/copy_references
Internal reference handling on content copying
2025-11-29 20:50:05 +00:00
Dan Brown
3cd3e73f60 Copying: Fixed issue with non-page links to page permalinks
Found during manual testing.
Added test case to cover.
2025-11-29 20:35:16 +00:00
Dan Brown
bb350639c6 Styles: Made non-active dark/light css variables exist by default
This means that it would be possible to jump between light/dark mode
with just the class, and no reload needed.
Not something we'll directly use right now, but may be useful in
customizations.
2025-11-27 21:56:45 +00:00
Dan Brown
46001d61d0 Updated version and assets for release v25.11.4 2025-11-25 22:23:36 +00:00
Dan Brown
8dd238ceae Updated version and assets for release v24.11.4 2025-11-25 21:48:32 +00:00
Dan Brown
bb7fd59de9 Merge branch 'v25-11' into release 2025-11-25 21:47:21 +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
959981a676 Copying: Added logic to find & update references 2025-11-25 17:52:26 +00:00
Dan Brown
674bb84fac Copying: Added reference change context tracking
Added core wiring in the cloning logic, just need to implement core
logic in the updater now.
2025-11-25 14:46:36 +00:00
Dan Brown
ba675b6349 Copying: Added tests to cover copy self-references
Logic to make tests pass to follow
2025-11-25 13:52:36 +00:00
Dan Brown
f073994bc3 Testing: Extracted copy tests to their own class 2025-11-25 12:36:33 +00:00
Dan Brown
0f40aeb0d3 Merge pull request #5913 from BookStackApp/slug_history
Slug History Tracking & Usage
2025-11-24 20:29:44 +00:00
Dan Brown
cdd164e3e3 Slugs: Added test to cover history lookup permission usage 2025-11-24 20:04:55 +00:00
Dan Brown
c90816987c Slugs: Rolled out history lookup to other types
Added testing to cover.
Also added batch recording of child slug pairs on book slug changes.
2025-11-24 19:49:34 +00:00
Dan Brown
dd393691b1 Slugs: Added lookup system using history
Switched page lookup to use this.
2025-11-24 13:55:11 +00:00
Dan Brown
dd5375f480 Slugs: Fixed storage bugs, added testing coverage 2025-11-24 10:46:24 +00:00
Dan Brown
291a807d98 Slugs: Added slug recording at points of generation
Also moved some model-level helpers, which used app container
resolution, to be injected services instead.
2025-11-23 23:29:30 +00:00
Dan Brown
e64fc60bdf Slugs: Created history table to track changes 2025-11-23 16:50:37 +00:00
Dan Brown
ad582ab9f8 DB testing: Prevented caching during build
To prevent re-using code when re-run for same branch.
2025-11-22 12:40:30 +00:00
Dan Brown
870f3c58c0 DB Testing: Updated supported DB list, added readme
Also fixed potentally flaky GIf test to be more accurate with single
frame GIF images.
2025-11-22 12:24:36 +00:00
Dan Brown
ad8fc95521 Updated version and assets for release v25.11.3 2025-11-21 14:02:09 +00:00
Dan Brown
cca066a258 Merge branch 'development' into release 2025-11-21 14:01:06 +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
bbda5fd468 Updated version and assets for release v25.11.2 2025-11-19 15:10:15 +00:00
Dan Brown
8429cc93eb Merge branch 'development' into release 2025-11-19 15:09:02 +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
fef61f054a Updated version and assets for release v25.11.1 2025-11-11 12:17:44 +00:00
Dan Brown
8082c95ec3 Merge branch 'development' into release 2025-11-11 12:15:16 +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
fcabf478de Updated version and assets for release v25.11 2025-11-09 12:52:34 +00:00
Dan Brown
8de2c28497 Merge branch 'development' into release 2025-11-09 12:51:26 +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
0838d5ea16 Updated version and assets for release v25.07.3 2025-10-05 15:38:50 +01:00
Dan Brown
449ac40114 Merge branch 'v25-07' into release 2025-10-05 15:37:20 +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
3131050acd Updated version and assets for release v25.07.2 2025-08-28 17:41:48 +01:00
Dan Brown
c0d2874892 Merge branch 'development' into release 2025-08-28 17:39:31 +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
5940a91809 Updated version and assets for release v25.07.1 2025-08-11 14:43:51 +01:00
Dan Brown
9a4651badb Merge branch 'development' into release 2025-08-11 14:43:13 +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
92d15d9cf2 Updated version and assets for release v25.07 2025-07-30 09:46:37 +01:00
Dan Brown
b06147fef7 Merge branch 'development' into release 2025-07-30 09:45:40 +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
841350a937 Updated version and assets for release v25.05.2 2025-07-07 15:01:24 +01:00
Dan Brown
12183bac07 Merge branch 'development' into release 2025-07-07 15:00:35 +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
e65b4b63a2 Updated version and assets for release v25.05.1 2025-06-17 15:30:40 +01:00
Dan Brown
7cac3f4780 Merge branch 'development' into release 2025-06-17 15:29:46 +01: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
92cd11d105 Updated version and assets for release v25.05 2025-05-31 14:27:44 +01:00
Dan Brown
13115ace84 Merge branch 'development' into release 2025-05-31 14:26:04 +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
73f9834e6f Updated version and assets for release v25.02.5 2025-05-17 12:16:55 +01:00
Dan Brown
3afe855156 Merge branch 'development' into release 2025-05-17 12:14:51 +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
Joshua Booth
b6110ed3cd Add settings for number of books/shelves that will be displayed per page 2025-05-13 20:17:36 +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
bfde896f0b Updated version and assets for release v25.02.4 2025-05-08 16:01:45 +01:00
Dan Brown
1cdc0a7a3d Merge branch 'development' into release 2025-05-08 15:57:02 +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
d19b86640b Updated version and assets for release v25.02.3 2025-05-05 18:32:39 +01:00
Dan Brown
2936ba609b Merge branch 'development' into release 2025-05-05 18:20:31 +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
573a2dd22a Updated version and assets for release v25.02.2 2025-04-02 17:32:58 +01:00
Dan Brown
b55cc803d3 Merge branch 'development' into release 2025-04-02 17:31:14 +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
304ade418e Updated version, assets, and checksums for release v25.02.1 2025-03-16 12:47:19 +00:00
Dan Brown
997931c42f Merge branch 'development' into release 2025-03-16 12:45:08 +00: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
Talstra Ruben SRSNL
da82e70ca3 Add optional OIDC avatar fetching from the “picture” claim 2025-01-20 17:21:46 +01:00
1111 changed files with 26019 additions and 10926 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)
@@ -469,3 +469,62 @@ 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
Petr Husák (petrhusak) :: Czech
Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian
Amr (amr3k) :: Arabic
Tahsin Ahmed (tahsinahmed2012) :: Bengali
bojan_che :: Serbian (Cyrillic)
setiawan setiawan (culture.setiawan) :: Indonesian
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
Gabriel Silver (GabrielBSilver) :: Hebrew
Tomas Darius Davainis (Tomasdd) :: Lithuanian
CriedHero :: Chinese Simplified
Henrik (henrik2105) :: Norwegian Bokmal
FoW (fofwisdom) :: Korean
serinf-lauza :: French
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
Shadluk Avan (quldosh) :: Uzbek
Marci (MartonPoto) :: Hungarian

View File

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

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php: ['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-2025, Dan Brown and the BookStack project contributors.
Copyright (c) 2015-2026, 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,33 +2,18 @@
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
{
public function __construct(
protected string $model
) {
}
/**
* Create a new instance of the model.
*/
public function createModel(): Model
{
$class = '\\' . ltrim($this->model, '\\');
return new $class();
}
/**
* Retrieve a user by their unique identifier.
*/
public function retrieveById(mixed $identifier): ?Authenticatable
{
return $this->createModel()->newQuery()->find($identifier);
return User::query()->find($identifier);
}
/**
@@ -59,10 +44,7 @@ class ExternalBaseUserProvider implements UserProvider
*/
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();
}

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

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

@@ -14,10 +14,9 @@ use PragmaRX\Google2FA\Support\Constants;
class TotpService
{
protected $google2fa;
public function __construct(Google2FA $google2fa)
{
public function __construct(
protected Google2FA $google2fa
) {
$this->google2fa = $google2fa;
// Use SHA1 as a default, Personal testing of other options in 2021 found
// many apps lack support for other algorithms yet still will scan
@@ -35,7 +34,7 @@ class TotpService
}
/**
* Generate a TOTP URL from secret key.
* Generate a TOTP URL from a secret key.
*/
public function generateUrl(string $secret, User $user): string
{

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
{

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

@@ -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,7 @@ 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;
@@ -13,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');

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,46 +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 $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);
}
/**
@@ -55,11 +77,27 @@ class Comment extends Model implements Loggable
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

@@ -0,0 +1,20 @@
<?php
namespace BookStack\Activity\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property string $mentionable_type
* @property int $mentionable_id
* @property int $from_user_id
* @property int $to_user_id
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class MentionHistory extends Model
{
protected $table = 'mention_history';
}

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

@@ -0,0 +1,85 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\MentionHistory;
use BookStack\Activity\Notifications\Messages\CommentMentionNotification;
use BookStack\Activity\Tools\MentionParser;
use BookStack\Entities\Models\Page;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
class CommentMentionNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
if (!($detail instanceof Comment) || !($detail->entity instanceof Page)) {
throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page");
}
/** @var Page $page */
$page = $detail->entity;
$parser = new MentionParser();
$mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html);
$realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get();
$receivingNotifications = $realMentionedUsers->filter(function (User $user) {
$prefs = new UserNotificationPreferences($user);
return $prefs->notifyOnCommentMentions();
});
$receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray();
$userMentionsToLog = $realMentionedUsers;
// When an edit, we check our history to see if we've already notified the user about this comment before
// so that we can filter them out to avoid double notifications.
if ($activity->type === ActivityType::COMMENT_UPDATE) {
$previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail);
$receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds));
$userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) {
return !in_array($user->id, $previouslyNotifiedUserIds);
});
}
$this->logMentions($userMentionsToLog, $detail, $user);
$this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page);
}
/**
* @param Collection<User> $mentionedUsers
*/
protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void
{
$mentions = [];
$now = Carbon::now();
foreach ($mentionedUsers as $mentionedUser) {
$mentions[] = [
'mentionable_type' => $comment->getMorphClass(),
'mentionable_id' => $comment->id,
'from_user_id' => $fromUser->id,
'to_user_id' => $mentionedUser->id,
'created_at' => $now,
'updated_at' => $now,
];
}
MentionHistory::query()->insert($mentions);
}
protected function getPreviouslyNotifiedUserIds(Comment $comment): array
{
return MentionHistory::query()
->where('mentionable_id', $comment->id)
->where('mentionable_type', $comment->getMorphClass())
->pluck('to_user_id')
->toArray();
}
}

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

@@ -0,0 +1,37 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class CommentMentionNotification extends BaseActivityNotification
{
public function toMail(User $notifiable): MailMessage
{
/** @var Comment $comment */
$comment = $this->detail;
/** @var Page $page */
$page = $comment->entity;
$locale = $notifiable->getLocale();
$listLines = array_filter([
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
$locale->trans('notifications.detail_commenter') => $this->user->name,
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
]);
return $this->newMailMessage($locale)
->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()]))
->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine($listLines))
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
->line($this->buildReasonFooterLine($locale));
}
}

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler;
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
@@ -48,5 +49,7 @@ class NotificationManager
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class);
}
}

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

@@ -0,0 +1,28 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Util\HtmlDocument;
use DOMElement;
class MentionParser
{
public function parseUserIdsFromHtml(string $html): array
{
$doc = new HtmlDocument($html);
$ids = [];
$mentionLinks = $doc->queryXPath('//a[@data-mention-user-id]');
foreach ($mentionLinks as $link) {
if ($link instanceof DOMElement) {
$id = intval($link->getAttribute('data-mention-user-id'));
if ($id > 0) {
$ids[] = $id;
}
}
}
return array_values(array_unique($ids));
}
}

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

@@ -83,7 +83,7 @@ class HomeController extends Controller
if ($homepageOption === 'bookshelves') {
$shelves = $this->queries->shelves->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(18);
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
$data = array_merge($commonData, ['shelves' => $shelves]);
return view('home.shelves', $data);
@@ -92,7 +92,7 @@ class HomeController extends Controller
if ($homepageOption === 'books') {
$books = $this->queries->books->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(18);
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
$data = array_merge($commonData, ['books' => $books]);
return view('home.books', $data);

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

@@ -3,6 +3,7 @@
namespace BookStack\App\Providers;
use BookStack\Access\SocialDriverManager;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Tools\ActivityLogger;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
@@ -73,6 +74,7 @@ class AppServiceProvider extends ServiceProvider
'book' => Book::class,
'chapter' => Chapter::class,
'page' => Page::class,
'comment' => Comment::class,
]);
}
}

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 => [

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

@@ -1,18 +0,0 @@
<?php
namespace BookStack\App;
/**
* Assigned to models that can have slugs.
* Must have the below properties.
*
* @property int $id
* @property string $name
*/
interface Sluggable
{
/**
* Regenerate the slug for this model.
*/
public function refreshSlug(): string;
}

View File

@@ -0,0 +1,13 @@
<?php
namespace BookStack\App;
/**
* Assigned to models that can have slugs.
* Must have the below properties.
*
* @property string $slug
*/
interface SluggableInterface
{
}

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,7 +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;
@@ -13,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') {
@@ -43,7 +40,7 @@ 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 (is_null($ownable)) {
return user()->can($permission);
@@ -59,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);

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

@@ -85,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,23 +65,24 @@ 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,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
// @phpstan-ignore class.notFound
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
@@ -88,7 +93,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 +104,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

@@ -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,9 +32,9 @@ return [
'local' => [
'driver' => 'local',
'root' => public_path(),
'visibility' => 'public',
'serve' => false,
'throw' => true,
'directory_visibility' => 'public',
],
'local_secure_attachments' => [
@@ -47,7 +47,6 @@ return [
'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' => null,
'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'),
'require_tls' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl' || $mailPort === 465),
],
'sendmail' => [

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

@@ -41,6 +41,7 @@ return [
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
'notifications#comment-mentions' => true,
],
];

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

@@ -8,6 +8,7 @@ use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
@@ -17,7 +18,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;
@@ -29,6 +32,7 @@ class BookController extends Controller
protected ShelfContext $shelfContext,
protected BookRepo $bookRepo,
protected BookQueries $queries,
protected EntityQueries $entityQueries,
protected BookshelfQueries $shelfQueries,
protected ReferenceFetcher $referenceFetcher,
) {
@@ -48,7 +52,7 @@ class BookController extends Controller
$books = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(18);
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
$popular = $this->queries->popularForList()->take(4)->get();
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
@@ -72,12 +76,12 @@ class BookController extends Controller
*/
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'));
@@ -95,7 +99,7 @@ class BookController extends Controller
*/
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 +111,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);
@@ -125,7 +129,16 @@ class BookController extends Controller
*/
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$book = $this->queries->findVisibleBySlugOrFail($slug);
try {
$book = $this->queries->findVisibleBySlugOrFail($slug);
} catch (NotFoundException $exception) {
$book = $this->entityQueries->findVisibleByOldSlugs('book', $slug);
if (is_null($book)) {
throw $exception;
}
return redirect($book->getUrl());
}
$bookChildren = (new BookContents($book))->getTree(true);
$bookParentShelves = $book->shelves()->scopes('visible')->get();
@@ -153,7 +166,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 +182,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 +209,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 +223,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 +238,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 +255,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 +271,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

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

@@ -6,11 +6,13 @@ use BookStack\Activity\ActivityQueries;
use BookStack\Activity\Models\View;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\BookshelfRepo;
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;
@@ -22,6 +24,7 @@ class BookshelfController extends Controller
public function __construct(
protected BookshelfRepo $shelfRepo,
protected BookshelfQueries $queries,
protected EntityQueries $entityQueries,
protected BookQueries $bookQueries,
protected ShelfContext $shelfContext,
protected ReferenceFetcher $referenceFetcher,
@@ -42,7 +45,7 @@ class BookshelfController extends Controller
$shelves = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(18);
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
$popular = $this->queries->popularForList()->get();
$new = $this->queries->visibleForList()
@@ -68,7 +71,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 +86,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'],
@@ -104,8 +107,17 @@ class BookshelfController extends Controller
*/
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-view', $shelf);
try {
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
} catch (NotFoundException $exception) {
$shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug);
if (is_null($shelf)) {
throw $exception;
}
return redirect($shelf->getUrl());
}
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
'default' => trans('common.sort_default'),
@@ -115,6 +127,7 @@ class BookshelfController extends Controller
]);
$sort = $listOptions->getSort();
$sortedVisibleShelfBooks = $shelf->visibleBooks()
->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder())
->get()
@@ -143,7 +156,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 +182,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 +208,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 +223,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);
@@ -75,8 +77,15 @@ class ChapterController extends Controller
*/
public function show(string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
try {
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
} catch (NotFoundException $exception) {
$chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug);
if (is_null($chapter)) {
throw $exception;
}
return redirect($chapter->getUrl());
}
$sidebarTree = (new BookContents($chapter->book))->getTree();
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
@@ -105,7 +114,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 +136,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 +151,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 +167,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 +183,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 +200,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 +229,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 +247,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 +257,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 +272,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

@@ -19,6 +19,7 @@ use BookStack\Entities\Tools\PageEditorData;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -49,7 +50,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()) {
@@ -81,7 +82,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 +100,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 +119,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());
@@ -137,9 +139,7 @@ class PageController extends Controller
try {
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
} catch (NotFoundException $e) {
$revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
$page = $revision->page ?? null;
$page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug);
if (is_null($page)) {
throw $e;
}
@@ -147,8 +147,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 +194,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 +218,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 +233,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 +270,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 +292,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 +315,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 +334,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 +381,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 +399,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 +428,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 +445,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 +456,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,9 +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;
@@ -15,26 +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 ?int $sort_rule_id
* @property Image|null $cover
* @property \Illuminate\Database\Eloquent\Collection $chapters
* @property \Illuminate\Database\Eloquent\Collection $pages
* @property \Illuminate\Database\Eloquent\Collection $directPages
* @property \Illuminate\Database\Eloquent\Collection $shelves
* @property ?Page $defaultTemplate
* @property ?SortRule $sortRule
* @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.
@@ -44,57 +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 the sort set assigned to this book, if existing.
*/
public function sortRule(): BelongsTo
{
return $this->belongsTo(SortRule::class);
}
/**
* Get all pages within this book.
* @return HasMany<Page, $this>
*/
public function pages(): HasMany
{
@@ -106,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
{
@@ -135,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

@@ -2,8 +2,6 @@
namespace BookStack\Entities\Models;
use BookStack\References\ReferenceUpdater;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
@@ -18,34 +16,10 @@ abstract class BookChild extends Entity
{
/**
* Get the book this page sits in.
* @return BelongsTo<Book, $this>
*/
public function book(): BelongsTo
{
return $this->belongsTo(Book::class)->withTrashed();
}
/**
* Change the book that this entity belongs to.
*/
public function changeBook(int $newBookId): Entity
{
$oldUrl = $this->getUrl();
$this->book_id = $newBookId;
$this->refreshSlug();
$this->save();
$this->refresh();
if ($oldUrl !== $this->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
}
// Update all child pages if a chapter
if ($this instanceof Chapter) {
foreach ($this->pages()->withTrashed()->get() as $page) {
$page->changeBook($newBookId);
}
}
return $this;
}
}

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,8 +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\Entities\Tools\SlugGenerator;
use BookStack\App\SluggableInterface;
use BookStack\Permissions\JointPermissionBuilder;
use BookStack\Permissions\Models\EntityPermission;
use BookStack\Permissions\Models\JointPermission;
@@ -22,37 +21,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 +78,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 +158,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 +169,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 +230,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 +254,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 +277,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 +337,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 +375,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 +391,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,21 +399,11 @@ 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);
}
/**
* {@inheritdoc}
*/
public function refreshSlug(): string
{
$this->slug = app()->make(SlugGenerator::class)->generate($this);
return $this->slug;
}
/**
* {@inheritdoc}
*/
@@ -344,6 +430,14 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
return $this->morphMany(Watch::class, 'watchable');
}
/**
* Get the related slug history for this entity.
*/
public function slugHistory(): MorphMany
{
return $this->morphMany(SlugHistory::class, 'sluggable');
}
/**
* {@inheritdoc}
*/
@@ -351,4 +445,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;
}

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