Compare commits

...

480 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
268e353431 Updated version and assets for release v25.02 2025-02-26 14:30:52 +00:00
Dan Brown
b491b5fbca Merge branch 'development' into release 2025-02-26 14:30:17 +00:00
Dan Brown
387c786768 Updated version and assets for release v24.12.1 2025-01-04 22:22:17 +00:00
Dan Brown
2641586a6f Merge branch 'development' into release 2025-01-04 22:22:04 +00:00
Dan Brown
6d2cd20e80 Updated version and assets for release v24.12 2024-12-23 11:55:23 +00:00
Dan Brown
b0c574356a Merge branch 'development' into release 2024-12-23 11:55:02 +00:00
Dan Brown
07e45a20e5 Updated version and assets for release v24.10.3 2024-11-29 13:50:41 +00:00
Dan Brown
14056c69e6 Updated version and assets for release v24.10.2 2024-11-29 13:47:24 +00:00
Dan Brown
fb9c840c46 Merge branch 'development' into release 2024-11-29 13:47:08 +00:00
Dan Brown
5fba4a5399 Updated version and assets for release v24.10.2 2024-11-13 12:03:15 +00:00
Dan Brown
c0b377050e Merge branch 'development' into release 2024-11-13 12:02:30 +00:00
Dan Brown
f3efb6441d Updated version and assets for release v24.10.1 2024-11-08 13:53:06 +00:00
Dan Brown
0cf313a21e Merge branch 'development' into release 2024-11-08 13:52:37 +00:00
Dan Brown
26aadffb20 Updated version and assets for release v24.10 2024-10-09 10:48:34 +01:00
Dan Brown
a5f48e3202 Merge branch 'development' into release 2024-10-09 10:46:07 +01:00
Dan Brown
b0dda6e6a7 Updated version and assets for release v24.05.4 2024-08-29 16:04:51 +01:00
Dan Brown
d4025d95e7 Merge branch 'development' into release 2024-08-29 16:04:37 +01:00
Dan Brown
d6021f4d22 Updated version and assets for release v24.05.3 2024-07-14 17:14:21 +01:00
Dan Brown
b9a3290731 Merge branch 'development' into release 2024-07-14 17:13:10 +01:00
Dan Brown
48f235ea5a Updated version and assets for release v24.05.2 2024-06-10 11:44:06 +01:00
Dan Brown
047771b9f4 Merge branch 'development' into release 2024-06-10 11:43:05 +01:00
Dan Brown
b5375114d3 Updated version and assets for release v24.05.1 2024-05-21 11:07:36 +01:00
Dan Brown
fc13e56cea Merge branch 'development' into release 2024-05-21 11:07:10 +01:00
Dan Brown
77fc37ac25 Updated version and assets for release v24.05 2024-05-11 15:49:29 +01:00
Dan Brown
3424351e84 Merge branch 'development' into release 2024-05-11 15:48:49 +01:00
Dan Brown
606f9d92d0 Updated version and assets for release v24.02.3 2024-04-05 15:20:08 +01:00
Dan Brown
a5e25abb9c Merge branch 'v24-02' into release 2024-04-05 15:19:34 +01:00
Dan Brown
b310e87e4c Updated version and assets for release v24.02.2 2024-03-11 14:30:48 +00:00
Dan Brown
425baf9d6e Merge branch 'development' into release 2024-03-10 18:46:05 +00:00
Dan Brown
825c369ad9 Updated version and assets for release v24.02 2024-02-28 13:35:36 +00:00
Dan Brown
10bab70438 Merge branch 'development' into release 2024-02-28 13:35:23 +00:00
Dan Brown
350e0b281b Updated version and assets for release v23.12.3 2024-02-26 12:05:02 +00:00
Dan Brown
08805ea3c8 Merge branch 'v23-12' into release 2024-02-26 12:04:25 +00:00
Dan Brown
9441e32c69 Updated version and assets for release v23.12.2 2024-01-24 10:37:20 +00:00
Dan Brown
530fc37067 Merge branch 'v23-12' into release 2024-01-24 10:36:52 +00:00
Dan Brown
369e499dce Updated version and assets for release v23.12.1 2024-01-16 12:16:06 +00:00
Dan Brown
655815de6d Merge branch 'development' into release 2024-01-16 12:15:50 +00:00
Dan Brown
457adc1fee Updated version and assets for release v23.12 2023-12-29 12:16:07 +00:00
Dan Brown
e86a90967e Merge branch 'development' into release 2023-12-29 12:15:34 +00:00
Dan Brown
5d08f7cf14 Updated version and assets for release v23.10.4 2023-11-20 14:19:46 +00:00
Dan Brown
8744eb2d62 Merge branch 'v23-10' into release 2023-11-20 14:02:23 +00:00
Dan Brown
d8383cfa80 Updated version and assets for release v23.10.2 2023-11-07 15:22:34 +00:00
Dan Brown
4626278447 Merge branch 'development' into release 2023-11-07 15:22:11 +00:00
Dan Brown
c61af9c22b Updated version and assets for release v23.10.1 2023-11-02 14:44:53 +00:00
Dan Brown
72521d0906 Merge branch 'development' into release 2023-11-02 14:35:49 +00:00
Dan Brown
7e44b195c5 Updated version and assets for release v23.10 2023-10-30 12:15:59 +00:00
Dan Brown
5b45eac5e1 Merge branch 'development' into release 2023-10-30 12:14:23 +00:00
Dan Brown
c1d30341e7 Updated version and assets for release v23.08.3 2023-09-15 13:49:40 +01:00
Dan Brown
80d2b4913b Merge branch 'v23-08' into release 2023-09-15 13:49:12 +01:00
Dan Brown
3f473528b1 Updated version and assets for release v23.08.2 2023-09-04 12:06:50 +01:00
Dan Brown
d0dcd4f61b Merge branch 'development' into release 2023-09-04 12:06:15 +01:00
Dan Brown
bde66a1396 Updated version and assets for release v23.08.1 2023-09-03 17:40:19 +01:00
Dan Brown
4de5a2d9bf Merge branch 'development' into release 2023-09-03 17:39:56 +01:00
Dan Brown
27bf4299cf Updated version and assets for release v23.08 2023-08-30 12:38:48 +01:00
Dan Brown
164f01bb25 Merge branch 'development' into release 2023-08-30 12:38:22 +01:00
Dan Brown
f563a005f5 Updated version and assets for release v23.06.2 2023-07-12 22:34:25 +01:00
Dan Brown
a14d8e30cc Merge branch 'development' into release 2023-07-12 22:34:15 +01:00
Dan Brown
a9194ffb63 Updated version and assets for release v23.06.1 2023-07-05 13:04:51 +01:00
Dan Brown
2f9c1b7127 Merge branch 'development' into release 2023-07-05 13:04:30 +01:00
Dan Brown
bbea76668b Updated version and assets for release v23.06 2023-06-30 11:06:19 +01:00
Dan Brown
becc630acf Merge branch 'development' into release 2023-06-30 11:05:57 +01:00
Dan Brown
4ac8ecad6b Updated version and assets for release v23.05.2 2023-05-23 12:36:46 +01:00
Dan Brown
903e88c700 Merge branch 'development' into release 2023-05-23 12:36:29 +01:00
Dan Brown
ed96aa820e Updated version and assets for release v23.05.1 2023-05-08 16:05:50 +01:00
Dan Brown
63ec079b7b Merge branch 'development' into release 2023-05-08 16:04:51 +01:00
Dan Brown
d485fcb3db Updated version and assets for release v23.05 2023-05-03 11:05:33 +01:00
Dan Brown
0f895668a4 Merge branch 'development' into release 2023-05-03 11:03:29 +01:00
Dan Brown
6c577ac3bf Updated version and assets for release v23.02.3 2023-04-07 18:07:32 +01:00
Dan Brown
31cc2423d2 Merge branch 'v23.02-branch' into release 2023-04-07 18:07:09 +01:00
Dan Brown
c9ed32e518 Updated version and assets for release v23.02.2 2023-03-25 12:27:32 +00:00
Dan Brown
6b4c3a0969 Merge branch 'v23.02-branch' into release 2023-03-25 12:27:05 +00:00
Dan Brown
2dad92d1bd Updated version and assets for release v23.02.1 2023-02-27 19:26:13 +00:00
Dan Brown
c1fb7ab7dc Merge branch 'development' into release 2023-02-27 19:23:33 +00:00
Dan Brown
98315f3899 Updated version and assets for release v23.02 2023-02-26 11:03:49 +00:00
Dan Brown
8c82aaabd6 Merge branch 'development' into release 2023-02-26 11:02:56 +00:00
Dan Brown
ce9b536b78 Updated version and assets for release v23.01.1 2023-02-02 12:29:26 +00:00
Dan Brown
d9c50e5bc1 Merge branch 'development' into release 2023-02-02 12:29:07 +00:00
Dan Brown
bf075f7dd8 Updated version and assets for release v23.01 2023-01-31 11:59:51 +00:00
Dan Brown
a4fd673285 Merge branch 'development' into release 2023-01-31 11:59:28 +00:00
Dan Brown
e794c977bc Updated version and assets for release v22.11.1 2022-12-16 23:49:14 +00:00
Dan Brown
0b088ef1d3 Merge branch 'development' into release 2022-12-16 23:48:35 +00:00
Dan Brown
bf6a6af683 Updated version and assets for release v22.11 2022-11-30 12:30:21 +00:00
Dan Brown
914790fd99 Merge branch 'development' into release 2022-11-30 12:29:52 +00:00
Dan Brown
edb0c6a9e8 Updated version and assets for release v22.10.2 2022-11-02 15:22:13 +00:00
Dan Brown
84049de696 Merge branch 'v22-10' into release 2022-11-02 15:19:33 +00:00
Dan Brown
da0531e63b Updated version and assets for release v22.10.1 2022-10-21 21:52:32 +01:00
Dan Brown
421dc75f4e Merge branch 'development' into release 2022-10-21 21:52:16 +01:00
Dan Brown
8ae91df038 Updated version and assets for release v22.10 2022-10-21 11:16:45 +01:00
Dan Brown
64b41dd626 Merge branch 'development' into release 2022-10-21 11:16:25 +01:00
Dan Brown
ebd6e4d3a2 Updated version and assets for release v22.09.1 2022-09-20 13:19:34 +01:00
Dan Brown
80374aea5c Merge branch 'development' into release 2022-09-20 13:19:03 +01:00
Dan Brown
2ac9efae7d Updated version and assets for release v22.09 2022-09-08 12:41:09 +01:00
Dan Brown
a11d565ba4 Merge branch 'development' into release 2022-09-08 12:40:57 +01:00
Dan Brown
1fdf854ea7 Updated version and assets for release v22.07.3 2022-08-11 15:17:06 +01:00
Dan Brown
e9c9792cb9 Merge branch 'development' into release 2022-08-11 15:16:34 +01:00
Dan Brown
5ae524c25a Updated version and assets for release v22.07.2 2022-08-09 13:55:52 +01:00
Dan Brown
0d7287fc8b Merge branch 'development' into release 2022-08-09 13:55:40 +01:00
Dan Brown
e77c96f6b7 Updated version and assets for release v22.07.1 2022-08-02 11:47:25 +01:00
Dan Brown
9b8a10dd3a Merge branch 'development' into release 2022-08-02 11:47:08 +01:00
Dan Brown
49200ca5ce Updated version and assets for release v22.07 2022-07-28 14:53:15 +01:00
Dan Brown
34aa4dbf10 Merge branch 'development' into release 2022-07-28 14:53:01 +01:00
Dan Brown
5ee79d16c9 Updated version and assets for release v22.06.2 2022-06-28 11:57:37 +01:00
Dan Brown
a1ea4006e0 Merge branch 'development' into release 2022-06-28 11:57:24 +01:00
Dan Brown
9078188939 Updated version and assets for release v22.06.1 2022-06-25 14:33:07 +01:00
Dan Brown
ed0aad1a7a Merge branch 'development' into release 2022-06-25 14:32:49 +01:00
Dan Brown
5c59cfb020 Updated version and assets for release v22.06 2022-06-24 11:50:56 +01:00
Dan Brown
3ca15ad68a Merge branch 'development' into release 2022-06-24 11:45:29 +01:00
Dan Brown
60014989f5 Updated version and assets for release v22.04.2 2022-05-09 16:10:16 +01:00
Dan Brown
57b10f195e Merge branch 'development' into release 2022-05-09 16:09:54 +01:00
Dan Brown
b1e95eb39f Updated version and assets for release v22.04.1 2022-05-04 21:26:58 +01:00
Dan Brown
b3da77b8f9 Merge branch 'development' into release 2022-05-04 21:26:31 +01:00
Dan Brown
1a345b74bb Updated version and assets for release v22.04 2022-04-29 15:55:32 +01:00
Dan Brown
8ffc3a4abf Merge branch 'development' into release 2022-04-29 15:55:05 +01:00
Dan Brown
7233c1c7b2 Updated version and assets for release v22.03.1 2022-03-30 19:37:07 +01:00
Dan Brown
1309a01131 Merge branch 'development' into release 2022-03-30 19:36:45 +01:00
Dan Brown
0333185b6d Updated version and assets for release v22.03 2022-03-30 13:49:17 +01:00
Dan Brown
83f89f64e8 Merge branch 'development' into release 2022-03-30 13:49:05 +01:00
Dan Brown
11a1a6fb16 Updated version and assets for release v22.02.3 2022-03-07 15:12:22 +00:00
Dan Brown
882c609296 Merge branch 'development' into release 2022-03-07 15:12:09 +00:00
Dan Brown
176a0dcd59 Updated version and assets for release v22.02.2 2022-03-01 22:45:41 +00:00
Dan Brown
94b0f70bfa Merge branch 'development' into release 2022-03-01 22:45:12 +00:00
Dan Brown
08b2a77d41 Updated version and assets for release v22.02.1 2022-02-27 17:46:06 +00:00
Dan Brown
3e8e9a23cf Merge branch 'development' into release 2022-02-27 17:45:49 +00:00
Dan Brown
58b83b64c8 Updated version and assets for release v22.02 2022-02-26 12:01:44 +00:00
Dan Brown
dfe4cde6ee Merge branch 'development' into release 2022-02-26 12:00:46 +00:00
Dan Brown
d11144d9e2 Updated version and assets for release v21.12.5 2022-02-06 15:49:23 +00:00
Dan Brown
f96b0ea5f3 Merge branch 'development' into release 2022-02-06 15:48:55 +00:00
Dan Brown
815f8d79ed Updated version and assets for release v21.12.4 2022-02-01 11:52:24 +00:00
Dan Brown
b62dab32e0 Merge branch 'development' into release 2022-02-01 11:51:48 +00:00
Dan Brown
262f863981 Updated version and assets for release v21.12.3 2022-01-24 22:49:42 +00:00
Dan Brown
a4c94390a1 Merge branch 'master' into release 2022-01-24 22:49:31 +00:00
Dan Brown
53f3cca85d Updated version and assets for release v21.12.2 2022-01-10 18:23:44 +00:00
Dan Brown
ed08bbcecc Merge branch 'master' into release 2022-01-10 18:23:19 +00:00
Dan Brown
de97ebf9b7 Updated version and assets for release v21.12.1 2022-01-06 12:20:37 +00:00
Dan Brown
f492a660a8 Merge branch 'master' into release 2022-01-06 12:20:26 +00:00
Dan Brown
09436836a5 Updated version and assets for release v21.12 2021-12-22 17:04:18 +00:00
Dan Brown
bb455d7788 Merge branch 'master' into release 2021-12-22 17:03:50 +00:00
Dan Brown
009212ab80 Updated version and assets for release v21.11.3 2021-12-15 14:08:37 +00:00
Dan Brown
ba9cb591c8 Merge branch 'master' into release 2021-12-15 14:08:17 +00:00
Dan Brown
d00ac2f34e Updated version and assets for release v21.11.2 2021-11-30 14:30:19 +00:00
Dan Brown
bd4dc6d463 Merge branch 'master' into release 2021-11-30 14:29:53 +00:00
Dan Brown
d91180a909 Updated version and assets for release v21.11.1 2021-11-23 20:44:36 +00:00
Dan Brown
bc2913a5cb Merge branch 'master' into release 2021-11-23 20:44:12 +00:00
Dan Brown
4802394562 Updated version and assets for release v21.11 2021-11-16 13:22:24 +00:00
Dan Brown
1755556468 Merge branch 'master' into release 2021-11-16 13:21:44 +00:00
Dan Brown
01cdbdb7ae Updated version and assets for release v21.10.3 2021-11-01 13:31:10 +00:00
Dan Brown
fc8bbf3eab Merge branch 'master' into release 2021-11-01 13:30:36 +00:00
Dan Brown
3cdab19319 Updated version and assets for release v21.10.2 2021-10-28 15:57:04 +01:00
Dan Brown
5661d20e87 Merge branch 'master' into release 2021-10-28 15:56:49 +01:00
Dan Brown
91f80123e8 Merge branch 'master' into release 2021-10-27 12:35:00 +01:00
Dan Brown
7a0636d0f8 Updated version and assets for release v21.10.1 2021-10-27 12:31:40 +01:00
Dan Brown
0fe5bdfbac Updated version and assets for release v21.10 2021-10-25 15:59:23 +01:00
Dan Brown
f88687e977 Merge branch 'master' into release 2021-10-25 15:58:59 +01:00
Dan Brown
68d437d05b Updated version and assets for release v21.08.6 2021-10-15 14:34:44 +01:00
Dan Brown
1e56aaea04 Merge branch 'master' into release 2021-10-15 14:34:23 +01:00
Dan Brown
dab170a6fe Updated version and assets for release v21.08.5 2021-10-08 22:25:36 +01:00
Dan Brown
a8de717d9b Merge branch 'master' into release 2021-10-08 22:25:05 +01:00
Dan Brown
78fe95b6fc Updated version and assets for release v21.08.4 2021-10-04 16:25:24 +01:00
Dan Brown
e0c24e41aa Merge branch 'master' into release 2021-10-04 16:24:54 +01:00
Dan Brown
fa8553839b Updated version and assets for release v21.08.3 2021-09-12 16:31:02 +01:00
Dan Brown
b8fcefc794 Merge branch 'master' into release 2021-09-12 16:30:35 +01:00
Dan Brown
88bcb68fcb Updated version and assets for release v21.08.2 2021-09-04 15:07:20 +01:00
Dan Brown
7c000553ae Merge branch 'master' into release 2021-09-04 15:06:33 +01:00
Dan Brown
391fa35c80 Updated version and assets for release v21.08.1 2021-09-02 21:13:09 +01:00
Dan Brown
c6773a8c9f Merge branch 'master' into release 2021-09-02 21:12:06 +01:00
Dan Brown
9b226e7d39 Updated version and assets for release v21.08 2021-08-31 22:07:53 +01:00
Dan Brown
9865446267 Merge branch 'master' into release 2021-08-31 22:07:23 +01:00
Dan Brown
926abbe776 Updated version and assets for release v21.05.4 2021-08-04 21:29:10 +01:00
Dan Brown
4fabef3a57 Merge branch 'v21.05.x' into release 2021-08-04 21:28:45 +01:00
Dan Brown
5ef4cd80c3 Updated version and assets for release v21.05.3 2021-07-03 11:59:52 +01:00
Dan Brown
e01f23583f Merge branch 'v21.05.x' into release 2021-07-03 11:59:21 +01:00
Dan Brown
7792cb3915 Updated version and assets for release v21.05.2 2021-06-13 14:26:34 +01:00
Dan Brown
be26253a18 Merge branch 'master' into release 2021-06-13 14:25:39 +01:00
Dan Brown
1bdd1f8189 Updated version for release v21.05.1 2021-06-04 23:09:42 +01:00
Dan Brown
fa62c79b17 Merge branch 'master' into release 2021-06-04 23:08:59 +01:00
Dan Brown
d7d8fa1e5b Updated version and assets for release v21.05 2021-05-30 16:17:56 +01:00
Dan Brown
18562f1e10 Merge branch 'master' into release 2021-05-30 16:17:44 +01:00
Dan Brown
86090a694f Updated version and assets for release v21.04.6 2021-05-24 13:06:03 +01:00
Dan Brown
1ee8287c73 Merge branch 'v21.04.x' into release 2021-05-24 13:05:34 +01:00
Dan Brown
8eb98cd591 Updated version and assets for release v21.04.5 2021-05-15 17:56:29 +01:00
Dan Brown
0f9ba21b05 Merge branch 'v21.04.x' into release 2021-05-15 17:56:03 +01:00
Dan Brown
834f8e7046 Updated version and assets for release v21.04.4 2021-05-09 14:46:05 +01:00
Dan Brown
32e3399334 Merge branch 'master' into release 2021-05-09 14:45:36 +01:00
Dan Brown
2d8698a218 Updated version and assets for release v21.04.3 2021-04-27 22:01:37 +01:00
Dan Brown
454fb883a2 Merge branch 'master' into release 2021-04-27 22:01:15 +01:00
Dan Brown
6f4a6ab8ea Updated version for release v21.04.2 2021-04-20 22:37:05 +01:00
Dan Brown
9c4b6f36f1 Merge branch 'master' into release 2021-04-20 22:36:35 +01:00
Dan Brown
78886b1e67 Updated version and assets for release v21.04.1 2021-04-19 22:26:19 +01:00
Dan Brown
d9debaf032 Merge branch 'master' into release 2021-04-19 22:25:29 +01:00
Dan Brown
d4360d6347 Updated version and assets for release v21.04 2021-04-09 21:18:32 +01:00
Dan Brown
175b1785c0 Merge branch 'master' into release 2021-04-09 21:18:09 +01:00
Dan Brown
c8740c0171 Updated version for release v0.31.8 2021-03-13 15:32:54 +00:00
Dan Brown
91ee895a74 Merge branch 'v0.31.x' into release 2021-03-13 15:32:06 +00:00
Dan Brown
a045e46571 Updated version for release v0.31.7 2021-03-02 21:19:17 +00:00
Dan Brown
44eaa65c3b Merge branch 'v0.31.x' into release 2021-03-02 21:18:31 +00:00
Dan Brown
0a22af7b14 Updated version for release v0.31.6 2021-02-06 14:41:19 +00:00
Dan Brown
b54702ab08 Merge branch 'v0.31.x' into release 2021-02-06 14:40:47 +00:00
Dan Brown
c4fdcfc5d1 Updated version for release v0.31.5 2021-02-02 20:58:06 +00:00
Dan Brown
cb8117e8df Merge branch 'v0.31.x' into release 2021-02-02 20:57:41 +00:00
Dan Brown
5a218d5056 Updated version and assets for release v0.31.4 2021-01-16 17:50:45 +00:00
Dan Brown
8dbc5cf9c6 Merge branch 'master' into release 2021-01-16 17:50:11 +00:00
Dan Brown
71e81615a3 Updated version for release v0.31.3 2021-01-10 23:29:58 +00:00
Dan Brown
611d37da04 Merge branch 'master' into release 2021-01-10 23:29:11 +00:00
Dan Brown
0e799a3857 Updated version and assets for release v0.31.2 2021-01-10 14:05:16 +00:00
Dan Brown
b91d6e2bfa Merge branch 'master' into release 2021-01-10 14:04:59 +00:00
Dan Brown
ea16ad7e94 Updated version and assets for release v0.31.1 2021-01-04 18:41:55 +00:00
Dan Brown
ba6eb54552 Merge branch 'master' into release 2021-01-04 18:41:26 +00:00
Dan Brown
f705e7683b Updated assets for release v0.31.0 again 2021-01-03 22:33:36 +00:00
Dan Brown
dc996adb20 Merge branch 'master' into release 2021-01-03 22:32:40 +00:00
Dan Brown
a64c638ccc Updated version and assets for release v0.31.0 2021-01-03 21:52:37 +00:00
Dan Brown
359c067279 Merge branch 'master' into release 2021-01-03 21:52:00 +00:00
Dan Brown
66a746e297 Updated version for release v0.30.7 2020-12-18 14:13:40 +00:00
Dan Brown
a4d43ee24b Merge branch 'v0.30.x' into release 2020-12-18 14:13:19 +00:00
Dan Brown
f7793a70a9 Updated version for release v0.30.6 2020-12-17 21:07:06 +00:00
Dan Brown
ceba3d31fb Merge branch 'v0.30.x' into release 2020-12-17 21:03:20 +00:00
Dan Brown
eecc08edde Updated version for release v0.30.5 2020-12-06 21:05:43 +00:00
Dan Brown
eb19aadc75 Merge branch 'v0.30.x' into release 2020-12-06 21:05:11 +00:00
Dan Brown
06c81e69b9 Updated version and assets for release v0.30.4 2020-10-31 16:52:33 +00:00
Dan Brown
3dc3d4a639 Merge branch 'master' into release 2020-10-31 16:51:54 +00:00
Dan Brown
94c59c1e3d Updated version and assets for release v0.30.3 2020-10-13 22:50:52 +01:00
Dan Brown
4d2205853a Merge branch 'master' into release 2020-10-13 22:50:30 +01:00
Dan Brown
751772b87a Updated version and assets for release v0.30.2 2020-09-30 22:44:58 +01:00
Dan Brown
76e30869e1 Merge branch 'master' into release 2020-09-30 22:44:17 +01:00
Dan Brown
3edc9fe9eb Updated version and assets for release v0.30.1 2020-09-26 17:51:37 +01:00
Dan Brown
616c62703e Merge branch 'master' into release 2020-09-26 17:50:25 +01:00
Dan Brown
ecd56917e7 Updated version and assets for release v0.30.0 2020-09-20 10:33:18 +01:00
Dan Brown
e22c9cae91 Merge branch 'master' into release 2020-09-20 10:30:10 +01:00
Dan Brown
29ddb6e1b9 Updated version and assets for release v0.29.3 2020-05-12 22:34:01 +01:00
Dan Brown
2ff90e2ff0 Merge branch 'master' into release 2020-05-12 22:33:27 +01:00
Dan Brown
04ecc128a2 Updated version and assets for release v0.29.2 2020-05-02 11:49:21 +01:00
Dan Brown
87d1d3423b Merge branch 'master' into release 2020-05-02 11:48:48 +01:00
Dan Brown
4818192a2a Updated version and assets for release v0.29.1 2020-04-28 12:30:31 +01:00
Dan Brown
965dd97f54 Merge branch 'master' into release 2020-04-28 12:30:09 +01:00
Dan Brown
195b74926c Updated version and assets for release v0.29.0 2020-04-13 16:10:23 +01:00
Dan Brown
2120db12b2 Merge branch 'master' into release 2020-04-13 16:10:11 +01:00
Dan Brown
ed563fef28 Updated version and assets for release v0.28.3 2020-03-14 22:31:42 +00:00
Dan Brown
0d31a8e3f1 Merge branch 'master' into release 2020-03-14 22:31:11 +00:00
Dan Brown
b8354b974b Updated version and assets for release v0.28.2 2020-02-15 22:36:08 +00:00
Dan Brown
034c1e289d Merge branch 'master' into release 2020-02-15 22:35:46 +00:00
Dan Brown
f31605a3de Updated version and assets for release v0.28.1 2020-02-15 22:08:06 +00:00
Dan Brown
e7cc75c74d Merge branch 'master' into release 2020-02-15 22:07:17 +00:00
Dan Brown
4b79d5e4e8 Updated version and assets for release v0.28.0 2020-02-03 22:44:45 +00:00
Dan Brown
34854915b3 Merge branch 'master' into release 2020-02-03 22:43:58 +00:00
Dan Brown
af6f34b529 Updated version and assets for release v0.27.5 2019-10-16 16:35:50 +01:00
Dan Brown
fb82a2b896 Merge branch 'patching-v0.27' into release 2019-10-16 16:35:10 +01:00
Dan Brown
5b464938b6 Updated version and assets for release v0.27.4 2019-09-07 13:30:08 +01:00
Dan Brown
81f954890d Merge branch 'patching-v0.27' into release 2019-09-07 13:29:53 +01:00
Dan Brown
0e2bbcec62 Updated version and assets for release v0.27.3 2019-09-03 21:50:12 +01:00
Dan Brown
fdd339f525 Merge branch 'master' into release 2019-09-03 21:49:46 +01:00
Dan Brown
8cf7d6a83d Updated version and assets for release v0.27.2 2019-09-01 12:12:23 +01:00
Dan Brown
58a5008718 Merge branch 'master' into release 2019-09-01 12:12:10 +01:00
Dan Brown
c44a8df55d Updated version and assets for release v0.27.1 2019-09-01 11:13:50 +01:00
Dan Brown
ff1494c519 Merge branch 'master' into release 2019-09-01 11:13:18 +01:00
Dan Brown
b8ce8fd852 Updated assets for release v0.27 2019-08-31 14:16:14 +01:00
Dan Brown
75e7454a5f Merge branch 'master' into release and set version 2019-08-31 14:15:18 +01:00
Dan Brown
2558ea8931 Updated version for release v0.26.4 2019-08-06 21:42:09 +01:00
Dan Brown
ac0f47a4b2 Merge branch 'v0.26' into release 2019-08-06 21:41:06 +01:00
Dan Brown
4f16129869 Updated version for release v0.26.3 2019-07-10 20:21:22 +01:00
Dan Brown
64a8037fdd Merge branch 'v0.26' into release 2019-07-10 20:19:54 +01:00
Dan Brown
7502ba1bc8 Updated version and assets for release v0.26.2 2019-05-27 13:48:20 +01:00
Dan Brown
33a04697ef Merge branch 'master' into release 2019-05-27 13:47:47 +01:00
Dan Brown
b70a5c0cdb Updated version and assets for release v0.26.1 2019-05-07 23:05:47 +01:00
Dan Brown
9443ae9f40 Merge branch 'master' into release 2019-05-07 23:05:10 +01:00
Dan Brown
220c2a4102 Updated version and assets for release v0.26.0 2019-05-06 18:58:56 +01:00
Dan Brown
e9914eb301 Merge branch 'master' into release 2019-05-06 18:57:58 +01:00
Dan Brown
934512d09c Updated version and assets for release v0.25.5 2019-03-24 19:45:17 +00:00
Dan Brown
9102c90986 Merge branch 'master' into release 2019-03-24 19:45:00 +00:00
Dan Brown
c3e74219c4 Updated version and assets for release v0.25.4 2019-03-21 19:46:19 +00:00
Dan Brown
13c9d7bc2d Merge branch 'master' into release 2019-03-21 19:43:48 +00:00
Dan Brown
119b539586 Updated version and assets for release v0.25.3 2019-03-21 00:03:26 +00:00
Dan Brown
29a5c180f0 Merge branch 'master' into release 2019-03-21 00:02:33 +00:00
Dan Brown
7906602291 Updated version and assets for release v0.25.2 2019-03-10 13:45:21 +00:00
Dan Brown
6dafe773ff Merge branch 'master' into release 2019-03-10 13:44:29 +00:00
Dan Brown
25bc28a1be Updated version and assets for release v0.25.1 2019-01-20 15:42:32 +00:00
Dan Brown
4c561c7fa0 Merge branch 'master' into release 2019-01-20 15:41:24 +00:00
Dan Brown
95b3e78573 Updated version and assets for release v0.25.0 2019-01-12 22:48:53 +00:00
Dan Brown
63a345bc93 Merge branch 'master' into release 2019-01-12 22:47:07 +00:00
Dan Brown
e093a172cb Updated assets and version for release v0.24.3 2018-11-27 21:52:20 +00:00
Dan Brown
4b01f8934b Merge branch 'master' into release 2018-11-27 21:51:32 +00:00
Dan Brown
bc116b45b5 Re-updated assets for release v0.24.2 2018-11-10 16:10:22 +00:00
Dan Brown
a059960b9e Merge branch 'master' into release 2018-11-10 16:09:14 +00:00
Dan Brown
7770966fed Updated assets for release v0.24.2 2018-11-10 16:01:55 +00:00
Dan Brown
d7adcf6c69 Merge branch 'master' into release 2018-11-10 16:01:01 +00:00
Dan Brown
04a364dcc3 Incremented version for v0.24.1 2018-09-24 16:34:16 +01:00
Dan Brown
db83ac7eaa Merge branch 'master' into release 2018-09-24 16:32:30 +01:00
Dan Brown
3ca9dddf61 Merge branch 'master' into release 2018-09-24 15:59:39 +01:00
Dan Brown
bf74f53ca7 Updated assets for release and incremented version 2018-09-24 12:18:27 +01:00
Dan Brown
9d67efb4a4 Merge branch 'master' into release 2018-09-24 12:08:21 +01:00
Dan Brown
3a39b9f440 Merge pull request #1022 from BookStackApp/revert-983-master
Revert "Update german translation"
2018-09-22 18:33:29 +01:00
Dan Brown
27f7aab375 Revert "Update german translation" 2018-09-22 18:33:15 +01:00
Dan Brown
337da0c467 Merge pull request #983 from vriic/master
Update german translation
2018-09-22 18:27:04 +01:00
Nikolai Nikolajevic
f56b3560c4 Update german translation 2018-08-23 16:17:46 +02:00
Dan Brown
02dfe11ce6 Increment version for release v0.23.2 2018-08-19 15:33:23 +01:00
Dan Brown
83d06beb70 Merge branch 'master' into release 2018-08-19 15:33:10 +01:00
Dan Brown
a8cfc059c8 Updated version for release v0.23.1 2018-08-12 14:22:53 +01:00
Dan Brown
1614b2bab0 Merge branch 'master' into release 2018-08-12 14:22:17 +01:00
Dan Brown
4bdec0d214 Updated version and assets for release v0.23 2018-07-29 20:28:49 +01:00
Dan Brown
6a7d7e7c2b Merge branch 'master' into release 2018-07-29 20:26:00 +01:00
Dan Brown
30d4674657 Updated assets for release v0.22 2018-05-28 14:19:14 +01:00
Dan Brown
9f961f95f8 Merge branch 'master' into release 2018-05-28 14:19:04 +01:00
Dan Brown
bab99a26ec Updated assets and version for v0.21 release 2018-04-22 20:21:22 +01:00
Dan Brown
9a7fecd269 Merge branch 'master' into release 2018-04-22 20:19:02 +01:00
Dan Brown
a8dc0d449b Updated the version because i'm such a plonker
And forgot to do this last release.
I wonder if there's a simple commit hook that could prevent the same two
versions twice in a row?
2018-03-30 15:41:46 +01:00
Dan Brown
a0381f76bf Merge branch 'v0.20' into release 2018-03-30 15:33:23 +01:00
Dan Brown
6102f66daa Updated assets for release v0.20.1 2018-03-25 16:58:14 +01:00
Dan Brown
c6134d162d Merge branch 'master' into release 2018-03-25 16:54:48 +01:00
Dan Brown
2046f9b9de Updated assets for release v0.20.0 2018-02-11 18:20:17 +00:00
Dan Brown
ac3ba594a4 Merge branch 'master' into release and updated version 2018-02-11 18:19:38 +00:00
Dan Brown
22df25a480 Updated assets and version for v0.19.0 2017-12-10 18:21:07 +00:00
Dan Brown
8b30c7f02e Merge branch 'master' into release 2017-12-10 18:19:20 +00:00
Dan Brown
757cdddc7c Updated version and JS for release v0.18.5 2017-11-11 18:33:04 +00:00
Dan Brown
df95e99680 Updated assets and version for release v0.18.4 2017-10-15 19:28:29 +01:00
Dan Brown
5a6d544db7 Merge branch 'master' into release 2017-10-15 19:27:50 +01:00
Dan Brown
16117d329c Merge branch 'master' into release, Updated version 2017-10-06 21:05:45 +01:00
Dan Brown
e90da18ada Updated assets and version for v0.18.2 release 2017-10-01 18:12:59 +01:00
Dan Brown
a08d80e1cc Merge branch 'master' into release 2017-10-01 18:12:07 +01:00
Dan Brown
6258175922 Updated assets and version for v0.18.1 release 2017-09-20 21:36:17 +01:00
Dan Brown
15736777a0 Merge branch 'master' into release 2017-09-20 21:35:33 +01:00
Dan Brown
75915e8a94 Updated assets for release v0.18 2017-09-10 17:07:57 +01:00
Dan Brown
9bde0ae4ea Merge branch 'master' into release 2017-09-10 17:05:05 +01:00
Dan Brown
0c802d1f86 Updated assets and version for release v0.17.4 2017-07-28 13:04:21 +01:00
Dan Brown
b7a96c6466 Merge branch 'master' into release 2017-07-28 13:03:36 +01:00
Dan Brown
4b645a82c7 Updated version for release 2017-07-22 17:27:01 +01:00
Dan Brown
d599b77b6f Merge branch 'master' into release 2017-07-22 17:26:44 +01:00
Dan Brown
26e93dc8c1 Updated assets and version for release v0.17.2 2017-07-22 16:49:07 +01:00
Dan Brown
a4c9a8491b Merge branch 'master' into release 2017-07-22 16:46:57 +01:00
Dan Brown
70ee636d87 Updated css and version for release 2017-07-10 20:52:32 +01:00
Dan Brown
b35f6dbb03 Merge branch 'master' into release 2017-07-10 20:51:25 +01:00
Dan Brown
67d9e24d8f Merge branch 'master' into release
Also updated assets, Version number
2017-07-02 22:52:26 +01:00
Dan Brown
3903fda6ca Incremented version 2017-06-04 15:38:49 +01:00
Dan Brown
441e46ebaa Merge branch 'v0.16' into release 2017-06-04 15:38:29 +01:00
Dan Brown
1f4260f359 Updated version for release v0.16.2 2017-05-07 19:35:51 +01:00
Dan Brown
dc0bf8ad4e Merge branch 'master' into release 2017-05-07 19:35:34 +01:00
Dan Brown
102e326e6a Updated JS and version for release v0.16.1 2017-04-30 19:51:23 +01:00
Dan Brown
2b25bf6f3b Merge branch 'master' into release 2017-04-30 19:50:29 +01:00
Dan Brown
f93280696d Updated assets for release v0.16 2017-04-23 20:42:28 +01:00
Dan Brown
1787391b07 Merge branch 'master' into release 2017-04-23 20:41:45 +01:00
Dan Brown
a74a8ee483 Updated version for v0.15.3 2017-03-23 22:22:16 +00:00
Dan Brown
7fa5405cb7 Merge branch 'master' into release 2017-03-23 22:21:04 +00:00
Dan Brown
6725ddcc41 Updated version for release v0.15.2 2017-03-05 15:50:52 +00:00
Dan Brown
bce941db3f Merge branch 'master' into release 2017-03-05 15:49:47 +00:00
Dan Brown
6d926048ec Updated to version v0.15.1 2017-02-27 16:59:10 +00:00
Dan Brown
5335c973b4 Merge branch 'master' into release 2017-02-27 16:58:20 +00:00
Dan Brown
15c3e5c96e Updated assets for release v0.15 2017-02-27 14:58:02 +00:00
Dan Brown
a5d5904969 Merge branch 'master' into release 2017-02-27 14:57:38 +00:00
Dan Brown
598758b991 Updated version for v0.14.3 2017-02-05 21:23:27 +00:00
Dan Brown
9926e23bc8 Merge branch 'v0.14' into release 2017-02-05 21:21:54 +00:00
Dan Brown
5d3264bc63 Updated assets for release v0.14.2 2017-02-01 22:27:04 +00:00
Dan Brown
d71f819f95 Merge branch 'v0.14' into release 2017-02-01 22:22:38 +00:00
Dan Brown
ee13509760 Updated version number 2017-01-23 22:28:31 +00:00
Dan Brown
82d7bb1f32 Merge branch 'master' into release 2017-01-23 22:28:02 +00:00
Dan Brown
cdfda508d8 Updated assets for release v0.14 2017-01-22 12:36:10 +00:00
Dan Brown
da941e584f Merge branch 'master' into release ready for v0.14 2017-01-22 12:31:27 +00:00
Dan Brown
65874d7b96 Updated assets for release v0.13.1 2016-11-27 19:42:33 +00:00
Dan Brown
ac9b8f405c Merge fixes from master for release v0.13.1 2016-11-27 19:41:12 +00:00
Dan Brown
8d1419a12e Update assets and version for release v0.13 2016-11-13 12:29:52 +00:00
Dan Brown
04f7a7d301 Merge branch 'master' into release 2016-11-13 12:26:56 +00:00
Dan Brown
c10d2a1493 Updated assets for release v0.12.2 2016-10-30 13:19:19 +00:00
Dan Brown
97bbf79ffd Merge branch 'v0.12' into release 2016-10-30 13:18:23 +00:00
Dan Brown
f7b01ae53d Updated assets for release v0.12.1 2016-09-06 20:50:15 +01:00
Dan Brown
d704e1dbba Merge branch 'master' into release 2016-09-06 20:49:15 +01:00
Dan Brown
ef2ff5e093 Updated assets for release v0.12 2016-09-05 19:49:42 +01:00
Dan Brown
7caed3b0db Merge branch 'master' into release 2016-09-05 19:35:21 +01:00
Dan Brown
45641d0754 Updated assets for release v0.11.2 2016-08-21 14:56:29 +01:00
Dan Brown
4b1d08ba99 Merge branch 'v0.11' into release 2016-08-21 14:55:11 +01:00
Dan Brown
160fa99ba4 Updated assets for release v0.11.1 2016-08-14 12:40:55 +01:00
Dan Brown
d2a5ab49ed Merge branch 'v0.11' into release 2016-08-14 12:37:48 +01:00
Dan Brown
c6404d8917 Updated assets for release v0.11 2016-07-03 10:56:16 +01:00
Dan Brown
7113807f12 Merge branch 'master' into release 2016-07-03 10:52:04 +01:00
Dan Brown
be711215e8 Updated assets for release v0.10 2016-05-22 15:12:47 +01:00
Dan Brown
7e3b404240 Merge branch 'master' into release for version v0.10 2016-05-22 15:11:50 +01:00
Dan Brown
e86901ca20 Updated assets for release v0.9.3 2016-05-03 21:13:02 +01:00
Dan Brown
bdfa61c8b2 Merge branch 'v0.9' into release 2016-05-03 21:11:01 +01:00
Dan Brown
2cc36787f5 Updated assets for release 0.9.2 2016-04-15 19:57:02 +01:00
Dan Brown
448ac61b48 Merge branch 'master' into release 2016-04-15 19:52:59 +01:00
Dan Brown
753f6394f7 Merge branch 'master' into release 2016-04-12 20:09:14 +01:00
Dan Brown
b1faf65934 Updated assets for release 0.9.0 2016-04-09 15:49:02 +01:00
Dan Brown
09f478bd74 Merge branch 'master' into release 2016-04-09 15:47:14 +01:00
Dan Brown
a0497feddd Updated assets for release 0.8.2 2016-03-30 21:44:30 +01:00
Dan Brown
789693bde9 Merge branch 'v0.8' into release 2016-03-30 21:32:46 +01:00
Dan Brown
1fe933e4ea Merge branch 'master' into release 2016-03-13 15:38:06 +00:00
Dan Brown
724b4b5a70 Updated assets for release 0.8.0 2016-03-13 15:15:14 +00:00
Dan Brown
1778a56146 Merge branch 'master' into release 2016-03-13 15:13:23 +00:00
Dan Brown
744865fcb2 Updated assets for release 0.7.6 2016-03-06 13:28:44 +00:00
Dan Brown
7f8c8b448d Merged branch master into release 2016-03-06 13:26:29 +00:00
Dan Brown
a67c53826d Updated assets for release 0.7.5 2016-02-25 21:24:09 +00:00
Dan Brown
14b131e850 Merge branch 'master' into release 2016-02-25 21:23:06 +00:00
Dan Brown
9b55a52b85 Updated assets for release 0.7.4 2016-02-11 22:35:01 +00:00
Dan Brown
db1d10e80f Merge branch 'master' into release 2016-02-11 22:29:29 +00:00
Dan Brown
1be576966f Updated assets for release 0.7.3 2016-02-08 20:47:33 +00:00
Dan Brown
b97e792c5f Merge branch 'master' into release 2016-02-08 20:45:48 +00:00
Dan Brown
8dec674cc3 Merge branch 'master' into release 2016-02-02 07:35:20 +00:00
Dan Brown
f784c03746 Merge branch 'master' into release 2016-02-01 18:31:04 +00:00
Dan Brown
148e172fe8 Updated assets for release 0.7 2016-01-31 18:03:55 +00:00
Dan Brown
56ae86646f Merge branch 'master' into release 2016-01-31 18:01:25 +00:00
Dan Brown
1d2b6fdfa2 Add updated assets 2016-01-02 14:50:59 +00:00
Dan Brown
4fc75beed4 Merge branch 'master' into release 2016-01-02 14:49:05 +00:00
Dan Brown
3b3bc0c4bf Updated compiled assets 2015-12-31 17:26:22 +00:00
Dan Brown
910faab88e Merge branch 'master' into release 2015-12-31 17:22:03 +00:00
Dan Brown
f184d763ad Added build folder to release 2015-12-16 17:53:53 +00:00
Dan Brown
a91d42634d Merge branch 'master' into release 2015-12-16 17:29:34 +00:00
Dan Brown
f517ef3616 Added new asset structure 2015-12-16 17:27:53 +00:00
Dan Brown
e99507ddcf Merge branch 'master' into release 2015-12-16 17:21:21 +00:00
Dan Brown
d2cacf1945 Release update 2015-12-01 21:30:21 +00:00
Dan Brown
448ac1405b Merge branch 'master' into release 2015-12-01 21:15:08 +00:00
Dan Brown
6ad21ce885 Added built assets for release 2015-11-30 21:59:34 +00:00
221 changed files with 4372 additions and 1277 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

@@ -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
@@ -509,3 +509,6 @@ iamwhoiamwhoami :: Swedish
Grogui :: French
MrCharlesIII :: Arabic
David Olsen (dawin) :: Danish
ltnzr :: French
Frank Holler (holler.frank) :: German; German Informal
Korab Arifi (korabidev) :: Albanian

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php: ['8.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

6
.gitignore vendored
View File

@@ -8,10 +8,10 @@ Homestead.yaml
.idea
npm-debug.log
yarn-error.log
/public/dist
/public/dist/*.map
/public/plugins
/public/css
/public/js
/public/css/*.map
/public/js/*.map
/public/bower
/public/build/
/public/favicon.ico

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

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

@@ -4,10 +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\Exceptions\PrettyException;
use BookStack\Facades\Activity as ActivityService;
use BookStack\Util\HtmlDescriptionFilter;
use Illuminate\Database\Eloquent\Builder;
class CommentRepo
{
@@ -19,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 $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();
@@ -38,6 +74,7 @@ class CommentRepo
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
$comment->refresh()->unsetRelations();
return $comment;
}
@@ -59,7 +96,7 @@ class CommentRepo
/**
* Archive an existing comment.
*/
public function archive(Comment $comment): 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);
@@ -68,7 +105,9 @@ class CommentRepo
$comment->archived = true;
$comment->save();
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
if ($log) {
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
}
return $comment;
}
@@ -76,7 +115,7 @@ class CommentRepo
/**
* Un-archive an existing comment.
*/
public function unarchive(Comment $comment): 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);
@@ -85,7 +124,9 @@ class CommentRepo
$comment->archived = false;
$comment->save();
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
if ($log) {
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
}
return $comment;
}

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

@@ -22,7 +22,7 @@ class CommentController extends Controller
/**
* Save a new comment for a Page.
*
* @throws ValidationException
* @throws ValidationException|\Exception
*/
public function savePageComment(Request $request, int $pageId)
{
@@ -37,11 +37,6 @@ 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(Permission::CommentCreateAll);
$contentRef = $input['content_ref'] ?? '';

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,22 +3,24 @@
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\Users\Models\User;
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 string $commentable_type
* @property int $commentable_id
* @property string $content_ref
* @property bool $archived
*/
@@ -28,13 +30,18 @@ class Comment extends Model implements Loggable, OwnableInterface
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');
return $this->morphTo('commentable');
}
/**
@@ -44,8 +51,8 @@ class Comment extends Model implements Loggable, OwnableInterface
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);
}
/**
@@ -58,11 +65,27 @@ class Comment extends Model implements Loggable, OwnableInterface
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

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

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

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

@@ -13,6 +13,11 @@ class CommentTree
* @var CommentTreeNode[]
*/
protected array $tree;
/**
* A linear array of loaded comments.
* @var Comment[]
*/
protected array $comments;
public function __construct(
@@ -39,7 +44,7 @@ class CommentTree
public function getActive(): array
{
return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived));
}
public function activeThreadCount(): int
@@ -49,7 +54,7 @@ class CommentTree
public function getArchived(): array
{
return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived));
}
public function archivedThreadCount(): int
@@ -79,6 +84,14 @@ 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[]
@@ -123,6 +136,9 @@ class CommentTree
return new CommentTreeNode($byId[$id], $depth, $children);
}
/**
* @return Comment[]
*/
protected function loadComments(): array
{
if (!$this->enabled()) {

View File

@@ -83,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;
});
}
@@ -140,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);
@@ -155,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) {
@@ -162,7 +180,7 @@ class ApiDocsGenerator
$this->reflectionClasses[$className] = $class;
}
return $class->getMethod($methodName);
return $class;
}
/**

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.

View File

@@ -45,10 +45,8 @@ 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'],

View File

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

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

@@ -116,6 +116,7 @@ class BookshelfController extends Controller
]);
$sort = $listOptions->getSort();
$sortedVisibleShelfBooks = $shelf->visibleBooks()
->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder())
->get()

View File

@@ -104,7 +104,7 @@ class ChapterApiController extends ApiController
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) {
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
try {
@@ -144,7 +144,7 @@ class ChapterApiController extends ApiController
$chapter->load(['tags']);
$chapter->makeVisible('description_html');
$chapter->setAttribute('description_html', $chapter->descriptionHtml());
$chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml());
/** @var Book $book */
$book = $chapter->book()->first();

View File

@@ -130,7 +130,7 @@ class ChapterController extends Controller
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->chapterRepo->update($chapter, $validated);
$chapter = $this->chapterRepo->update($chapter, $validated);
return redirect($chapter->getUrl());
}

View File

@@ -2,6 +2,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo;
@@ -88,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);
}
/**

View File

@@ -120,6 +120,7 @@ class PageController extends Controller
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
]);
$draftPage = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent());

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 CoverImageInterface, HtmlDescriptionInterface
class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface
{
use HasFactory;
use HtmlDescriptionTrait;
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,55 +44,6 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
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>
@@ -107,7 +58,7 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
*/
public function directPages(): HasMany
{
return $this->pages()->where('chapter_id', '=', '0');
return $this->pages()->whereNull('chapter_id');
}
/**
@@ -137,4 +88,27 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
}
public function defaultTemplate(): EntityDefaultTemplate
{
return new EntityDefaultTemplate($this);
}
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
public function coverInfo(): EntityCover
{
return new EntityCover($this);
}
/**
* Get the sort rule assigned to this container, if existing.
*/
public function sortRule(): BelongsTo
{
return $this->belongsTo(SortRule::class);
}
}

View File

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

View File

@@ -2,34 +2,34 @@
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityCover;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionInterface
/**
* @property string $description
* @property string $description_html
*/
class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInterface
{
use HasFactory;
use HtmlDescriptionTrait;
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,41 +50,6 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
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.
* @return BelongsTo<Image, $this>
*/
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.
*/
@@ -96,7 +61,7 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
/**
* 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;
@@ -106,12 +71,13 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
$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,27 +2,25 @@
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 implements HtmlDescriptionInterface
class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface
{
use HasFactory;
use HtmlDescriptionTrait;
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.
@@ -50,14 +48,6 @@ class Chapter extends BookChild implements HtmlDescriptionInterface
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.
* @return Collection<Page>
@@ -70,4 +60,9 @@ class Chapter extends BookChild implements HtmlDescriptionInterface
->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

@@ -1,18 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
interface CoverImageInterface
{
/**
* 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

@@ -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;
@@ -17,6 +18,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
*/
class Deletion extends Model implements Loggable
{
use HasFactory;
protected $hidden = [];
/**

View File

@@ -28,23 +28,25 @@ 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 $owned_by
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $owned_by
* @property Collection $tags
*
* @method static Entity|Builder visible()
@@ -77,6 +79,72 @@ abstract class Entity extends Model implements
*/
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.
*/
@@ -91,8 +159,8 @@ abstract class Entity extends Model implements
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);
@@ -102,11 +170,12 @@ abstract class Entity extends Model implements
/**
* 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]);
}
@@ -162,15 +231,17 @@ abstract class Entity extends Model implements
*/
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;
}
@@ -184,7 +255,7 @@ abstract class Entity extends Model implements
}
/**
* Get this entities restrictions.
* Get this entities assigned permissions.
*/
public function permissions(): MorphMany
{
@@ -267,7 +338,7 @@ abstract class Entity extends Model implements
}
/**
* 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
{
@@ -377,4 +448,40 @@ abstract class Entity extends Model implements
{
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

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

View File

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

View File

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

View File

@@ -1,17 +0,0 @@
<?php
namespace BookStack\Entities\Models;
interface HtmlDescriptionInterface
{
/**
* Get the HTML-based description for this item.
* By default, the content should be sanitised unless raw is set to true.
*/
public function descriptionHtml(bool $raw = false): string;
/**
* Set the HTML-based description for this item.
*/
public function setDescriptionHtml(string $html, string|null $plaintext = null): void;
}

View File

@@ -1,35 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Util\HtmlContentFilter;
/**
* @property string $description
* @property string $description_html
*/
trait HtmlDescriptionTrait
{
public function descriptionHtml(bool $raw = false): string
{
$html = $this->description_html ?: '<p>' . nl2br(e($this->description)) . '</p>';
if ($raw) {
return $html;
}
return HtmlContentFilter::removeScriptsFromHtmlString($html);
}
public function setDescriptionHtml(string $html, string|null $plaintext = null): void
{
$this->description_html = $html;
if ($plaintext !== null) {
$this->description = $plaintext;
}
if (empty($html) && !empty($plaintext)) {
$this->description_html = $this->descriptionHtml();
}
}
}

View File

@@ -3,7 +3,6 @@
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditorType;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Uploads\Attachment;
use Illuminate\Database\Eloquent\Builder;
@@ -15,7 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* Class Page.
*
* @property EntityPageData $pageData
* @property int $chapter_id
* @property string $html
* @property string $markdown
@@ -33,12 +32,10 @@ class Page extends BookChild
{
use HasFactory;
protected $fillable = ['name', 'priority'];
public string $textField = 'text';
public string $htmlField = 'html';
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at', 'entity_id', 'entity_type'];
protected $fillable = ['name', 'priority'];
protected $casts = [
'draft' => 'boolean',
@@ -57,10 +54,8 @@ class Page extends BookChild
/**
* Get the chapter that this page is in, If applicable.
*
* @return BelongsTo
*/
public function chapter()
public function chapter(): BelongsTo
{
return $this->belongsTo(Chapter::class);
}
@@ -107,10 +102,8 @@ class Page extends BookChild
/**
* Get the attachments assigned to this page.
*
* @return HasMany
*/
public function attachments()
public function attachments(): HasMany
{
return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
}
@@ -139,8 +132,16 @@ class Page extends BookChild
$refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']);
$refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));
$refreshed->setAttribute('raw_html', $refreshed->html);
$refreshed->html = (new PageContent($refreshed))->render();
$refreshed->setAttribute('html', (new PageContent($refreshed))->render());
return $refreshed;
}
/**
* @return HasOne<EntityPageData, $this>
*/
public function relatedData(): HasOne
{
return $this->hasOne(EntityPageData::class, 'page_id', 'id');
}
}

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\Models\Loggable;
use BookStack\App\Model;
use BookStack\Users\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
@@ -30,6 +31,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
*/
class PageRevision extends Model implements Loggable
{
use HasFactory;
protected $fillable = ['name', 'text', 'summary'];
protected $hidden = ['html', 'markdown', 'text'];

View File

@@ -55,6 +55,11 @@ class BookQueries implements ProvidesEntityQueries
->select(static::$listAttributes);
}
public function visibleForContent(): Builder
{
return $this->start()->scopes('visible');
}
public function visibleForListWithCover(): Builder
{
return $this->visibleForList()->with('cover');

View File

@@ -60,6 +60,11 @@ class BookshelfQueries implements ProvidesEntityQueries
return $this->start()->scopes('visible')->select(static::$listAttributes);
}
public function visibleForContent(): Builder
{
return $this->start()->scopes('visible');
}
public function visibleForListWithCover(): Builder
{
return $this->visibleForList()->with('cover');

View File

@@ -65,8 +65,14 @@ class ChapterQueries implements ProvidesEntityQueries
->scopes('visible')
->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) {
$builder->select('slug')
->from('books')
->whereColumn('books.id', '=', 'chapters.book_id');
->from('entities as books')
->where('type', '=', 'book')
->whereColumn('books.id', '=', 'entities.book_id');
}]));
}
public function visibleForContent(): Builder
{
return $this->start()->scopes('visible');
}
}

View File

@@ -3,7 +3,11 @@
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityTable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class EntityQueries
@@ -32,17 +36,53 @@ class EntityQueries
return $queries->findVisibleById($entityId);
}
/**
* Start a query across all entity types.
* Combines the description/text fields into a single 'description' field.
* @return Builder<EntityTable>
*/
public function visibleForList(): Builder
{
$rawDescriptionField = DB::raw('COALESCE(description, text) as description');
$bookSlugSelect = function (QueryBuilder $query) {
return $query->select('slug')->from('entities as books')
->whereColumn('books.id', '=', 'entities.book_id')
->where('type', '=', 'book');
};
return EntityTable::query()->scopes('visible')
->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', 'book_slug' => $bookSlugSelect, $rawDescriptionField])
->leftJoin('entity_container_data', function (JoinClause $join) {
$join->on('entity_container_data.entity_id', '=', 'entities.id')
->on('entity_container_data.entity_type', '=', 'entities.type');
})->leftJoin('entity_page_data', function (JoinClause $join) {
$join->on('entity_page_data.page_id', '=', 'entities.id')
->where('entities.type', '=', 'page');
});
}
/**
* Start a query of visible entities of the given type,
* suitable for listing display.
* @return Builder<Entity>
*/
public function visibleForList(string $entityType): Builder
public function visibleForListForType(string $entityType): Builder
{
$queries = $this->getQueriesForType($entityType);
return $queries->visibleForList();
}
/**
* Start a query of visible entities of the given type,
* suitable for using the contents of the items.
* @return Builder<Entity>
*/
public function visibleForContentForType(string $entityType): Builder
{
$queries = $this->getQueriesForType($entityType);
return $queries->visibleForContent();
}
protected function getQueriesForType(string $type): ProvidesEntityQueries
{
$queries = match ($type) {

View File

@@ -13,7 +13,7 @@ class PageQueries implements ProvidesEntityQueries
{
protected static array $contentAttributes = [
'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft',
'template', 'html', 'text', 'created_at', 'updated_at', 'priority',
'template', 'html', 'markdown', 'text', 'created_at', 'updated_at', 'priority',
'created_by', 'updated_by', 'owned_by',
];
protected static array $listAttributes = [
@@ -82,6 +82,14 @@ class PageQueries implements ProvidesEntityQueries
->select($this->mergeBookSlugForSelect(static::$listAttributes));
}
/**
* @return Builder<Page>
*/
public function visibleForContent(): Builder
{
return $this->start()->scopes('visible');
}
public function visibleForChapterList(int $chapterId): Builder
{
return $this->visibleForList()
@@ -104,18 +112,19 @@ class PageQueries implements ProvidesEntityQueries
->where('created_by', '=', user()->id);
}
public function visibleTemplates(): Builder
public function visibleTemplates(bool $includeContents = false): Builder
{
return $this->visibleForList()
->where('template', '=', true);
$base = $includeContents ? $this->visibleWithContents() : $this->visibleForList();
return $base->where('template', '=', true);
}
protected function mergeBookSlugForSelect(array $columns): array
{
return array_merge($columns, ['book_slug' => function ($builder) {
$builder->select('slug')
->from('books')
->whereColumn('books.id', '=', 'pages.book_id');
->from('entities as books')
->where('type', '=', 'book')
->whereColumn('books.id', '=', 'entities.book_id');
}]);
}
}

View File

@@ -35,4 +35,11 @@ interface ProvidesEntityQueries
* @return Builder<TModel>
*/
public function visibleForList(): Builder;
/**
* Start a query for items that are visible, with selection
* configured for using the content of the items found.
* @return Builder<TModel>
*/
public function visibleForContent(): Builder;
}

View File

@@ -3,13 +3,10 @@
namespace BookStack\Entities\Repos;
use BookStack\Activity\TagRepo;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\HasCoverInterface;
use BookStack\Entities\Models\HasDescriptionInterface;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\CoverImageInterface;
use BookStack\Entities\Models\HtmlDescriptionInterface;
use BookStack\Entities\Models\HtmlDescriptionTrait;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore;
@@ -33,17 +30,25 @@ class BaseRepo
/**
* Create a new entity in the system.
* @template T of Entity
* @param T $entity
* @return T
*/
public function create(Entity $entity, array $input)
public function create(Entity $entity, array $input): Entity
{
$entity = (clone $entity)->refresh();
$entity->fill($input);
$this->updateDescription($entity, $input);
$entity->forceFill([
'created_by' => user()->id,
'updated_by' => user()->id,
'owned_by' => user()->id,
]);
$entity->refreshSlug();
if ($entity instanceof HasDescriptionInterface) {
$this->updateDescription($entity, $input);
}
$entity->save();
if (isset($input['tags'])) {
@@ -53,24 +58,33 @@ class BaseRepo
$entity->refresh();
$entity->rebuildPermissions();
$entity->indexForSearch();
$this->referenceStore->updateForEntity($entity);
return $entity;
}
/**
* Update the given entity.
* @template T of Entity
* @param T $entity
* @return T
*/
public function update(Entity $entity, array $input)
public function update(Entity $entity, array $input): Entity
{
$oldUrl = $entity->getUrl();
$entity->fill($input);
$this->updateDescription($entity, $input);
$entity->updated_by = user()->id;
if ($entity->isDirty('name') || empty($entity->slug)) {
$entity->refreshSlug();
}
if ($entity instanceof HasDescriptionInterface) {
$this->updateDescription($entity, $input);
}
$entity->save();
if (isset($input['tags'])) {
@@ -84,59 +98,35 @@ class BaseRepo
if ($oldUrl !== $entity->getUrl()) {
$this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
}
return $entity;
}
/**
* Update the given items' cover image, or clear it.
* Update the given items' cover image or clear it.
*
* @throws ImageUploadException
* @throws \Exception
*/
public function updateCoverImage(Entity&CoverImageInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false)
public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void
{
if ($coverImage) {
$imageType = $entity->coverImageTypeKey();
$this->imageRepo->destroyImage($entity->cover()->first());
$imageType = 'cover_' . $entity->type;
$this->imageRepo->destroyImage($entity->coverInfo()->getImage());
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
$entity->cover()->associate($image);
$entity->coverInfo()->setImage($image);
$entity->save();
}
if ($removeImage) {
$this->imageRepo->destroyImage($entity->cover()->first());
$entity->cover()->dissociate();
$this->imageRepo->destroyImage($entity->coverInfo()->getImage());
$entity->coverInfo()->setImage(null);
$entity->save();
}
}
/**
* Update the default page template used for this item.
* Checks that, if changing, the provided value is a valid template and the user
* has visibility of the provided page template id.
*/
public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): void
{
$changing = $templateId !== intval($entity->default_template_id);
if (!$changing) {
return;
}
if ($templateId === 0) {
$entity->default_template_id = null;
$entity->save();
return;
}
$templateExists = $this->pageQueries->visibleTemplates()
->where('id', '=', $templateId)
->exists();
$entity->default_template_id = $templateExists ? $templateId : null;
$entity->save();
}
/**
* Sort the parent of the given entity, if any auto sort actions are set for it.
* Sort the parent of the given entity if any auto sort actions are set for it.
* Typically ran during create/update/insert events.
*/
public function sortParent(Entity $entity): void
@@ -147,19 +137,22 @@ class BaseRepo
}
}
/**
* Update the description of the given entity from input data.
*/
protected function updateDescription(Entity $entity, array $input): void
{
if (!($entity instanceof HtmlDescriptionInterface)) {
if (!$entity instanceof HasDescriptionInterface) {
return;
}
if (isset($input['description_html'])) {
$entity->setDescriptionHtml(
$entity->descriptionInfo()->set(
HtmlDescriptionFilter::filterFromString($input['description_html']),
html_entity_decode(strip_tags($input['description_html']))
);
} else if (isset($input['description'])) {
$entity->setDescriptionHtml('', $input['description']);
$entity->descriptionInfo()->set('', $input['description']);
}
}
}

View File

@@ -30,19 +30,18 @@ class BookRepo
public function create(array $input): Book
{
return (new DatabaseTransaction(function () use ($input) {
$book = new Book();
$this->baseRepo->create($book, $input);
$book = $this->baseRepo->create(new Book(), $input);
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
$book->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null));
Activity::add(ActivityType::BOOK_CREATE, $book);
$defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
$book->sort_rule_id = $defaultBookSortSetting;
$book->save();
}
$book->save();
return $book;
}))->run();
}
@@ -52,28 +51,29 @@ class BookRepo
*/
public function update(Book $book, array $input): Book
{
$this->baseRepo->update($book, $input);
$book = $this->baseRepo->update($book, $input);
if (array_key_exists('default_template_id', $input)) {
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id']));
$book->defaultTemplate()->setFromId(intval($input['default_template_id']));
}
if (array_key_exists('image', $input)) {
$this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
}
$book->save();
Activity::add(ActivityType::BOOK_UPDATE, $book);
return $book;
}
/**
* Update the given book's cover image, or clear it.
* Update the given book's cover image or clear it.
*
* @throws ImageUploadException
* @throws Exception
*/
public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false)
public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false): void
{
$this->baseRepo->updateCoverImage($book, $coverImage, $removeImage);
}
@@ -83,7 +83,7 @@ class BookRepo
*
* @throws Exception
*/
public function destroy(Book $book)
public function destroy(Book $book): void
{
$this->trashCan->softDestroyBook($book);
Activity::add(ActivityType::BOOK_DELETE, $book);

View File

@@ -25,8 +25,7 @@ class BookshelfRepo
public function create(array $input, array $bookIds): Bookshelf
{
return (new DatabaseTransaction(function () use ($input, $bookIds) {
$shelf = new Bookshelf();
$this->baseRepo->create($shelf, $input);
$shelf = $this->baseRepo->create(new Bookshelf(), $input);
$this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
$this->updateBooks($shelf, $bookIds);
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
@@ -39,7 +38,7 @@ class BookshelfRepo
*/
public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf
{
$this->baseRepo->update($shelf, $input);
$shelf = $this->baseRepo->update($shelf, $input);
if (!is_null($bookIds)) {
$this->updateBooks($shelf, $bookIds);
@@ -96,7 +95,7 @@ class BookshelfRepo
*
* @throws Exception
*/
public function destroy(Bookshelf $shelf)
public function destroy(Bookshelf $shelf): void
{
$this->trashCan->softDestroyShelf($shelf);
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);

View File

@@ -33,8 +33,11 @@ class ChapterRepo
$chapter = new Chapter();
$chapter->book_id = $parentBook->id;
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
$this->baseRepo->create($chapter, $input);
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
$chapter = $this->baseRepo->create($chapter, $input);
$chapter->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null));
$chapter->save();
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
$this->baseRepo->sortParent($chapter);
@@ -48,12 +51,13 @@ class ChapterRepo
*/
public function update(Chapter $chapter, array $input): Chapter
{
$this->baseRepo->update($chapter, $input);
$chapter = $this->baseRepo->update($chapter, $input);
if (array_key_exists('default_template_id', $input)) {
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id']));
$chapter->defaultTemplate()->setFromId(intval($input['default_template_id']));
}
$chapter->save();
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
$this->baseRepo->sortParent($chapter);
@@ -66,7 +70,7 @@ class ChapterRepo
*
* @throws Exception
*/
public function destroy(Chapter $chapter)
public function destroy(Chapter $chapter): void
{
$this->trashCan->softDestroyChapter($chapter);
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
@@ -93,7 +97,7 @@ class ChapterRepo
}
return (new DatabaseTransaction(function () use ($chapter, $parent) {
$chapter->changeBook($parent->id);
$chapter = $chapter->changeBook($parent->id);
$chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);

View File

@@ -9,11 +9,9 @@ use BookStack\Facades\Activity;
class DeletionRepo
{
private TrashCan $trashCan;
public function __construct(TrashCan $trashCan)
{
$this->trashCan = $trashCan;
public function __construct(
protected TrashCan $trashCan
) {
}
public function restore(int $id): int

View File

@@ -37,7 +37,7 @@ class PageRepo
/**
* Get a new draft page belonging to the given parent entity.
*/
public function getNewDraftPage(Entity $parent)
public function getNewDraftPage(Entity $parent): Page
{
$page = (new Page())->forceFill([
'name' => trans('entities.pages_initial_name'),
@@ -46,6 +46,9 @@ class PageRepo
'updated_by' => user()->id,
'draft' => true,
'editor' => PageEditorType::getSystemDefault()->value,
'html' => '',
'markdown' => '',
'text' => '',
]);
if ($parent instanceof Chapter) {
@@ -55,17 +58,18 @@ class PageRepo
$page->book_id = $parent->id;
}
$defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate;
if ($defaultTemplate && userCan(Permission::PageView, $defaultTemplate)) {
$defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get();
if ($defaultTemplate) {
$page->forceFill([
'html' => $defaultTemplate->html,
'markdown' => $defaultTemplate->markdown,
]);
$page->text = (new PageContent($page))->toPlainText();
}
(new DatabaseTransaction(function () use ($page) {
$page->save();
$page->refresh()->rebuildPermissions();
$page->rebuildPermissions();
}))->run();
return $page;
@@ -81,7 +85,8 @@ class PageRepo
$draft->revision_count = 1;
$draft->priority = $this->getNewPriority($draft);
$this->updateTemplateStatusAndContentFromInput($draft, $input);
$this->baseRepo->update($draft, $input);
$draft = $this->baseRepo->update($draft, $input);
$draft->rebuildPermissions();
$summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
@@ -112,12 +117,12 @@ class PageRepo
public function update(Page $page, array $input): Page
{
// Hold the old details to compare later
$oldHtml = $page->html;
$oldName = $page->name;
$oldHtml = $page->html;
$oldMarkdown = $page->markdown;
$this->updateTemplateStatusAndContentFromInput($page, $input);
$this->baseRepo->update($page, $input);
$page = $this->baseRepo->update($page, $input);
// Update with new details
$page->revision_count++;
@@ -176,12 +181,12 @@ class PageRepo
/**
* Save a page update draft.
*/
public function updatePageDraft(Page $page, array $input)
public function updatePageDraft(Page $page, array $input): Page|PageRevision
{
// If the page itself is a draft simply update that
// If the page itself is a draft, simply update that
if ($page->draft) {
$this->updateTemplateStatusAndContentFromInput($page, $input);
$page->fill($input);
$page->forceFill(array_intersect_key($input, array_flip(['name'])))->save();
$page->save();
return $page;
@@ -209,7 +214,7 @@ class PageRepo
*
* @throws Exception
*/
public function destroy(Page $page)
public function destroy(Page $page): void
{
$this->trashCan->softDestroyPage($page);
Activity::add(ActivityType::PAGE_DELETE, $page);
@@ -279,7 +284,7 @@ class PageRepo
return (new DatabaseTransaction(function () use ($page, $parent) {
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
$page->changeBook($newBookId);
$page = $page->changeBook($newBookId);
$page->rebuildPermissions();
Activity::add(ActivityType::PAGE_MOVE, $page);

View File

@@ -23,7 +23,7 @@ class RevisionRepo
/**
* Get a user update_draft page revision to update for the given page.
* Checks for an existing revisions before providing a fresh one.
* Checks for an existing revision before providing a fresh one.
*/
public function getNewDraftForCurrentUser(Page $page): PageRevision
{
@@ -72,7 +72,7 @@ class RevisionRepo
/**
* Delete old revisions, for the given page, from the system.
*/
protected function deleteOldRevisions(Page $page)
protected function deleteOldRevisions(Page $page): void
{
$revisionLimit = config('app.revision_limit');
if ($revisionLimit === false) {

View File

@@ -3,13 +3,10 @@
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Sorting\BookSortMap;
use BookStack\Sorting\BookSortMapItem;
use Illuminate\Support\Collection;
class BookContents
@@ -29,7 +26,7 @@ class BookContents
{
$maxPage = $this->book->pages()
->where('draft', '=', false)
->where('chapter_id', '=', 0)
->whereDoesntHave('chapter')
->max('priority');
$maxChapter = $this->book->chapters()
@@ -80,11 +77,11 @@ class BookContents
protected function bookChildSortFunc(): callable
{
return function (Entity $entity) {
if (isset($entity['draft']) && $entity['draft']) {
if ($entity->getAttribute('draft') ?? false) {
return -100;
}
return $entity['priority'] ?? 0;
return $entity->getAttribute('priority') ?? 0;
};
}

View File

@@ -6,8 +6,8 @@ use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\HasCoverInterface;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\CoverImageInterface;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\ChapterRepo;
@@ -106,8 +106,8 @@ class Cloner
$inputData['tags'] = $this->entityTagsToInputArray($entity);
// Add a cover to the data if existing on the original entity
if ($entity instanceof CoverImageInterface) {
$cover = $entity->cover()->first();
if ($entity instanceof HasCoverInterface) {
$cover = $entity->coverInfo()->getImage();
if ($cover) {
$inputData['image'] = $this->imageToUploadedFile($cover);
}

View File

@@ -0,0 +1,75 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Builder;
class EntityCover
{
public function __construct(
protected Book|Bookshelf $entity,
) {
}
protected function imageQuery(): Builder
{
return Image::query()->where('id', '=', $this->entity->image_id);
}
/**
* Check if a cover image exists for this entity.
*/
public function exists(): bool
{
return $this->entity->image_id !== null && $this->imageQuery()->exists();
}
/**
* Get the assigned cover image model.
*/
public function getImage(): Image|null
{
if ($this->entity->image_id === null) {
return null;
}
$cover = $this->imageQuery()->first();
if ($cover instanceof Image) {
return $cover;
}
return null;
}
/**
* Returns a cover image URL, or the given default if none assigned/existing.
*/
public function getUrl(int $width = 440, int $height = 250, string|null $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='): string|null
{
if (!$this->entity->image_id) {
return $default;
}
try {
return $this->getImage()?->getThumb($width, $height, false) ?? $default;
} catch (Exception $err) {
return $default;
}
}
/**
* Set the image to use as the cover for this entity.
*/
public function setImage(Image|null $image): void
{
if ($image === null) {
$this->entity->image_id = null;
} else {
$this->entity->image_id = $image->id;
}
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\PageQueries;
class EntityDefaultTemplate
{
public function __construct(
protected Book|Chapter $entity,
) {
}
/**
* Set the default template ID for this entity.
*/
public function setFromId(int $templateId): void
{
$changing = $templateId !== intval($this->entity->default_template_id);
if (!$changing) {
return;
}
if ($templateId === 0) {
$this->entity->default_template_id = null;
return;
}
$pageQueries = app()->make(PageQueries::class);
$templateExists = $pageQueries->visibleTemplates()
->where('id', '=', $templateId)
->exists();
$this->entity->default_template_id = $templateExists ? $templateId : null;
}
/**
* Get the default template for this entity (if visible).
*/
public function get(): Page|null
{
if (!$this->entity->default_template_id) {
return null;
}
$pageQueries = app()->make(PageQueries::class);
$page = $pageQueries->visibleTemplates(true)
->where('id', '=', $this->entity->default_template_id)
->first();
if ($page instanceof Page) {
return $page;
}
return null;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Util\HtmlContentFilter;
class EntityHtmlDescription
{
protected string $html = '';
protected string $plain = '';
public function __construct(
protected Book|Chapter|Bookshelf $entity,
) {
$this->html = $this->entity->description_html ?? '';
$this->plain = $this->entity->description ?? '';
}
/**
* Update the description from HTML code.
* Optionally takes plaintext to use for the model also.
*/
public function set(string $html, string|null $plaintext = null): void
{
$this->html = $html;
$this->entity->description_html = $this->html;
if ($plaintext !== null) {
$this->plain = $plaintext;
$this->entity->description = $this->plain;
}
if (empty($html) && !empty($plaintext)) {
$this->html = $this->getHtml();
$this->entity->description_html = $this->html;
}
}
/**
* Get the description as HTML.
* Optionally returns the raw HTML if requested.
*/
public function getHtml(bool $raw = false): string
{
$html = $this->html ?: '<p>' . nl2br(e($this->plain)) . '</p>';
if ($raw) {
return $html;
}
return HtmlContentFilter::removeScriptsFromHtmlString($html);
}
public function getPlain(): string
{
return $this->plain;
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityTable;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use Illuminate\Database\Eloquent\Collection;
class EntityHydrator
{
public function __construct(
protected EntityQueries $entityQueries,
) {
}
/**
* Hydrate the entities of this hydrator to return a list of entities represented
* in their original intended models.
* @param EntityTable[] $entities
* @return Entity[]
*/
public function hydrate(array $entities, bool $loadTags = false, bool $loadParents = false): array
{
$hydrated = [];
foreach ($entities as $entity) {
$data = $entity->getRawOriginal();
$instance = Entity::instanceFromType($entity->type);
if ($instance instanceof Page) {
$data['text'] = $data['description'];
unset($data['description']);
}
$instance = $instance->setRawAttributes($data, true);
$hydrated[] = $instance;
}
if ($loadTags) {
$this->loadTagsIntoModels($hydrated);
}
if ($loadParents) {
$this->loadParentsIntoModels($hydrated);
}
return $hydrated;
}
/**
* @param Entity[] $entities
*/
protected function loadTagsIntoModels(array $entities): void
{
$idsByType = [];
$entityMap = [];
foreach ($entities as $entity) {
if (!isset($idsByType[$entity->type])) {
$idsByType[$entity->type] = [];
}
$idsByType[$entity->type][] = $entity->id;
$entityMap[$entity->type . ':' . $entity->id] = $entity;
}
$query = Tag::query();
foreach ($idsByType as $type => $ids) {
$query->orWhere(function ($query) use ($type, $ids) {
$query->where('entity_type', '=', $type)
->whereIn('entity_id', $ids);
});
}
$tags = empty($idsByType) ? [] : $query->get()->all();
$tagMap = [];
foreach ($tags as $tag) {
$key = $tag->entity_type . ':' . $tag->entity_id;
if (!isset($tagMap[$key])) {
$tagMap[$key] = [];
}
$tagMap[$key][] = $tag;
}
foreach ($entityMap as $key => $entity) {
$entityTags = new Collection($tagMap[$key] ?? []);
$entity->setRelation('tags', $entityTags);
}
}
/**
* @param Entity[] $entities
*/
protected function loadParentsIntoModels(array $entities): void
{
$parentsByType = ['book' => [], 'chapter' => []];
foreach ($entities as $entity) {
if ($entity->getAttribute('book_id') !== null) {
$parentsByType['book'][] = $entity->getAttribute('book_id');
}
if ($entity->getAttribute('chapter_id') !== null) {
$parentsByType['chapter'][] = $entity->getAttribute('chapter_id');
}
}
$parentQuery = $this->entityQueries->visibleForList();
$filtered = count($parentsByType['book']) > 0 || count($parentsByType['chapter']) > 0;
$parentQuery = $parentQuery->where(function ($query) use ($parentsByType) {
foreach ($parentsByType as $type => $ids) {
if (count($ids) > 0) {
$query = $query->orWhere(function ($query) use ($type, $ids) {
$query->where('type', '=', $type)
->whereIn('id', $ids);
});
}
}
});
$parentModels = $filtered ? $parentQuery->get()->all() : [];
$parents = $this->hydrate($parentModels);
$parentMap = [];
foreach ($parents as $parent) {
$parentMap[$parent->type . ':' . $parent->id] = $parent;
}
foreach ($entities as $entity) {
if ($entity instanceof Page || $entity instanceof Chapter) {
$key = 'book:' . $entity->getRawAttribute('book_id');
$entity->setRelation('book', $parentMap[$key] ?? null);
}
if ($entity instanceof Page) {
$key = 'chapter:' . $entity->getRawAttribute('chapter_id');
$entity->setRelation('chapter', $parentMap[$key] ?? null);
}
}
}
}

View File

@@ -34,6 +34,7 @@ class HierarchyTransformer
/** @var Page $page */
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
$page->save();
$page->changeBook($book->id);
}

View File

@@ -19,7 +19,7 @@ class MixedEntityListLoader
* This will look for a model id and type via 'name_id' and 'name_type'.
* @param Model[] $relations
*/
public function loadIntoRelations(array $relations, string $relationName, bool $loadParents): void
public function loadIntoRelations(array $relations, string $relationName, bool $loadParents, bool $withContents = false): void
{
$idsByType = [];
foreach ($relations as $relation) {
@@ -33,7 +33,7 @@ class MixedEntityListLoader
$idsByType[$type][] = $id;
}
$modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents);
$modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents, $withContents);
foreach ($relations as $relation) {
$type = $relation->getAttribute($relationName . '_type');
@@ -49,13 +49,13 @@ class MixedEntityListLoader
* @param array<string, int[]> $idsByType
* @return array<string, array<int, Model>>
*/
protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents): array
protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents, bool $withContents): array
{
$modelMap = [];
foreach ($idsByType as $type => $ids) {
$models = $this->queries->visibleForList($type)
->whereIn('id', $ids)
$base = $withContents ? $this->queries->visibleForContentForType($type) : $this->queries->visibleForListForType($type);
$models = $base->whereIn('id', $ids)
->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : [])
->get();

View File

@@ -284,7 +284,7 @@ class PageContent
/**
* Get a plain-text visualisation of this page.
*/
protected function toPlainText(): string
public function toPlainText(): string
{
$html = $this->render(true);

View File

@@ -6,14 +6,16 @@ use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\EntityContainerData;
use BookStack\Entities\Models\HasCoverInterface;
use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\CoverImageInterface;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity;
use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
use BookStack\Util\DatabaseTransaction;
use Exception;
@@ -140,6 +142,7 @@ class TrashCan
protected function destroyShelf(Bookshelf $shelf): int
{
$this->destroyCommonRelations($shelf);
$shelf->books()->detach();
$shelf->forceDelete();
return 1;
@@ -167,6 +170,7 @@ class TrashCan
}
$this->destroyCommonRelations($book);
$book->shelves()->detach();
$book->forceDelete();
return $count + 1;
@@ -209,15 +213,16 @@ class TrashCan
$attachmentService->deleteFile($attachment);
}
// Remove book template usages
$this->queries->books->start()
// Remove use as a template
EntityContainerData::query()
->where('default_template_id', '=', $page->id)
->update(['default_template_id' => null]);
// Remove chapter template usages
$this->queries->chapters->start()
->where('default_template_id', '=', $page->id)
->update(['default_template_id' => null]);
// Nullify uploaded image relations
Image::query()
->whereIn('type', ['gallery', 'drawio'])
->where('uploaded_to', '=', $page->id)
->update(['uploaded_to' => null]);
$page->forceDelete();
@@ -268,8 +273,8 @@ class TrashCan
// exists in the event it has already been destroyed during this request.
$entity = $deletion->deletable()->first();
$count = 0;
if ($entity) {
$count = $this->destroyEntity($deletion->deletable);
if ($entity instanceof Entity) {
$count = $this->destroyEntity($entity);
}
$deletion->delete();
@@ -398,9 +403,11 @@ class TrashCan
$entity->referencesTo()->delete();
$entity->referencesFrom()->delete();
if ($entity instanceof CoverImageInterface && $entity->cover()->exists()) {
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
$imageService = app()->make(ImageService::class);
$imageService->destroy($entity->cover()->first());
$imageService->destroy($entity->coverInfo()->getImage());
}
$entity->relatedData()->delete();
}
}

View File

@@ -284,7 +284,7 @@ class ExportFormatter
public function bookToPlainText(Book $book): string
{
$bookTree = (new BookContents($book))->getTree(false, true);
$text = $book->name . "\n" . $book->description;
$text = $book->name . "\n" . $book->descriptionInfo()->getPlain();
$text = rtrim($text) . "\n\n";
$parts = [];
@@ -318,7 +318,7 @@ class ExportFormatter
{
$text = '# ' . $chapter->name . "\n\n";
$description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert();
$description = (new HtmlToMarkdown($chapter->descriptionInfo()->getHtml()))->convert();
if ($description) {
$text .= $description . "\n\n";
}
@@ -338,7 +338,7 @@ class ExportFormatter
$bookTree = (new BookContents($book))->getTree(false, true);
$text = '# ' . $book->name . "\n\n";
$description = (new HtmlToMarkdown($book->descriptionHtml()))->convert();
$description = (new HtmlToMarkdown($book->descriptionInfo()->getHtml()))->convert();
if ($description) {
$text .= $description . "\n\n";
}

View File

@@ -55,10 +55,10 @@ final class ZipExportBook extends ZipExportModel
$instance = new self();
$instance->id = $model->id;
$instance->name = $model->name;
$instance->description_html = $model->descriptionHtml();
$instance->description_html = $model->descriptionInfo()->getHtml();
if ($model->cover) {
$instance->cover = $files->referenceForImage($model->cover);
if ($model->coverInfo()->exists()) {
$instance->cover = $files->referenceForImage($model->coverInfo()->getImage());
}
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());

View File

@@ -40,7 +40,7 @@ final class ZipExportChapter extends ZipExportModel
$instance = new self();
$instance->id = $model->id;
$instance->name = $model->name;
$instance->description_html = $model->descriptionHtml();
$instance->description_html = $model->descriptionInfo()->getHtml();
$instance->priority = $model->priority;
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());

View File

@@ -15,6 +15,7 @@ use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Permissions\Permission;
use BookStack\Uploads\Attachment;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
class ZipExportReferences
{
@@ -33,6 +34,7 @@ class ZipExportReferences
public function __construct(
protected ZipReferenceParser $parser,
protected ImageService $imageService,
) {
}
@@ -133,10 +135,17 @@ class ZipExportReferences
return "[[bsexport:image:{$model->id}]]";
}
// Find and include images if in visibility
// Get the page which we'll reference this image upon
$page = $model->getPage();
$pageExportModel = $this->pages[$page->id] ?? ($exportModel instanceof ZipExportPage ? $exportModel : null);
if (isset($this->images[$model->id]) || ($page && $pageExportModel && userCan(Permission::PageView, $page))) {
$pageExportModel = null;
if ($page && isset($this->pages[$page->id])) {
$pageExportModel = $this->pages[$page->id];
} elseif ($exportModel instanceof ZipExportPage) {
$pageExportModel = $exportModel;
}
// Add the image to the export if it's accessible or just return the existing reference if already added
if (isset($this->images[$model->id]) || ($pageExportModel && $this->imageService->imageAccessible($model))) {
if (!isset($this->images[$model->id])) {
$exportImage = ZipExportImage::fromModel($model, $files);
$this->images[$model->id] = $exportImage;
@@ -144,6 +153,7 @@ class ZipExportReferences
}
return "[[bsexport:image:{$model->id}]]";
}
return null;
}

View File

@@ -135,8 +135,8 @@ class ZipImportRunner
'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
]);
if ($book->cover) {
$this->references->addImage($book->cover, null);
if ($book->coverInfo()->getImage()) {
$this->references->addImage($book->coverInfo()->getImage(), null);
}
$children = [
@@ -197,8 +197,8 @@ class ZipImportRunner
$this->pageRepo->publishDraft($page, [
'name' => $exportPage->name,
'markdown' => $exportPage->markdown,
'html' => $exportPage->html,
'markdown' => $exportPage->markdown ?? '',
'html' => $exportPage->html ?? '',
'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
]);

View File

@@ -8,6 +8,12 @@ use Illuminate\Http\JsonResponse;
abstract class ApiController extends Controller
{
/**
* The validation rules for this controller.
* Can alternative be defined in a rules() method is they need to be dynamic.
*
* @var array<string, array<string, string[]>>
*/
protected array $rules = [];
/**

View File

@@ -48,9 +48,7 @@ enum Permission: string
case AttachmentUpdateAll = 'attachment-update-all';
case AttachmentUpdateOwn = 'attachment-update-own';
case CommentCreate = 'comment-create';
case CommentCreateAll = 'comment-create-all';
case CommentCreateOwn = 'comment-create-own';
case CommentDelete = 'comment-delete';
case CommentDeleteAll = 'comment-delete-all';
case CommentDeleteOwn = 'comment-delete-own';

View File

@@ -40,10 +40,6 @@ class PermissionApplicator
$ownerField = $ownable->getOwnerFieldName();
$ownableFieldVal = $ownable->getAttribute($ownerField);
if (is_null($ownableFieldVal)) {
throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
}
$isOwner = $user->id === $ownableFieldVal;
$hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
@@ -144,10 +140,10 @@ class PermissionApplicator
/** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
$query->select('page_id')->from('entity_page_data')
->whereColumn('entity_page_data.page_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
->where('pages.draft', '=', false);
->where('entity_page_data.draft', '=', false);
});
});
}
@@ -197,18 +193,18 @@ class PermissionApplicator
{
$fullPageIdColumn = $tableName . '.' . $pageIdColumn;
return $this->restrictEntityQuery($query)
->where(function ($query) use ($fullPageIdColumn) {
/** @var Builder $query */
$query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullPageIdColumn)
->where('pages.draft', '=', false);
})->orWhereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullPageIdColumn)
->where('pages.draft', '=', true)
->where('pages.created_by', '=', $this->currentUser()->id);
});
->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('entities')
->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id')
->whereColumn('entities.id', '=', $fullPageIdColumn)
->where('entities.type', '=', 'page')
->where(function (QueryBuilder $query) {
$query->where('entity_page_data.draft', '=', false)
->orWhere(function (QueryBuilder $query) {
$query->where('entity_page_data.draft', '=', true)
->where('entities.created_by', '=', $this->currentUser()->id);
});
});
});
}

View File

@@ -20,10 +20,10 @@ class ReferenceFetcher
* Query and return the references pointing to the given entity.
* Loads the commonly required relations while taking permissions into account.
*/
public function getReferencesToEntity(Entity $entity): Collection
public function getReferencesToEntity(Entity $entity, bool $withContents = false): Collection
{
$references = $this->queryReferencesToEntity($entity)->get();
$this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', true);
$this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', false, $withContents);
return $references;
}

View File

@@ -3,9 +3,9 @@
namespace BookStack\References;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\HasDescriptionInterface;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HtmlDescriptionInterface;
use BookStack\Entities\Models\HtmlDescriptionTrait;
use BookStack\Entities\Models\EntityContainerData;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\RevisionRepo;
use BookStack\Util\HtmlDocument;
@@ -36,7 +36,7 @@ class ReferenceUpdater
protected function getReferencesToUpdate(Entity $entity): array
{
/** @var Reference[] $references */
$references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all();
$references = $this->referenceFetcher->getReferencesToEntity($entity, true)->values()->all();
if ($entity instanceof Book) {
$pages = $entity->pages()->get(['id']);
@@ -44,7 +44,7 @@ class ReferenceUpdater
$children = $pages->concat($chapters);
foreach ($children as $bookChild) {
/** @var Reference[] $childRefs */
$childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all();
$childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild, true)->values()->all();
array_push($references, ...$childRefs);
}
}
@@ -64,16 +64,16 @@ class ReferenceUpdater
$this->updateReferencesWithinPage($entity, $oldLink, $newLink);
}
if ($entity instanceof HtmlDescriptionInterface) {
if ($entity instanceof HasDescriptionInterface) {
$this->updateReferencesWithinDescription($entity, $oldLink, $newLink);
}
}
protected function updateReferencesWithinDescription(Entity&HtmlDescriptionInterface $entity, string $oldLink, string $newLink): void
protected function updateReferencesWithinDescription(Entity&HasDescriptionInterface $entity, string $oldLink, string $newLink): void
{
$entity = (clone $entity)->refresh();
$html = $this->updateLinksInHtml($entity->descriptionHtml(true) ?: '', $oldLink, $newLink);
$entity->setDescriptionHtml($html);
$description = $entity->descriptionInfo();
$html = $this->updateLinksInHtml($description->getHtml(true) ?: '', $oldLink, $newLink);
$description->set($html);
$entity->save();
}

View File

@@ -1,10 +1,13 @@
<?php
declare(strict_types=1);
namespace BookStack\Search;
use BookStack\Api\ApiEntityListFormatter;
use BookStack\Entities\Models\Entity;
use BookStack\Http\ApiController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SearchApiController extends ApiController
@@ -31,11 +34,9 @@ class SearchApiController extends ApiController
* between: bookshelf, book, chapter & page.
*
* The paging parameters and response format emulates a standard listing endpoint
* but standard sorting and filtering cannot be done on this endpoint. If a count value
* is provided this will only be taken as a suggestion. The results in the response
* may currently be up to 4x this value.
* but standard sorting and filtering cannot be done on this endpoint.
*/
public function all(Request $request)
public function all(Request $request): JsonResponse
{
$this->validate($request, $this->rules['all']);

View File

@@ -7,6 +7,7 @@ use BookStack\Entities\Queries\QueryPopular;
use BookStack\Entities\Tools\SiblingFetcher;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
class SearchController extends Controller
{
@@ -23,20 +24,21 @@ class SearchController extends Controller
{
$searchOpts = SearchOptions::fromRequest($request);
$fullSearchString = $searchOpts->toString();
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
$page = intval($request->get('page', '0')) ?: 1;
$nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1));
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
$formatter->format($results['results']->all(), $searchOpts);
$paginator = new LengthAwarePaginator($results['results'], $results['total'], 20, $page);
$paginator->setPath('/search');
$paginator->appends($request->except('page'));
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
return view('search.all', [
'entities' => $results['results'],
'totalResults' => $results['total'],
'paginator' => $paginator,
'searchTerm' => $fullSearchString,
'hasNextPage' => $results['has_more'],
'nextPageLink' => $nextPageLink,
'options' => $searchOpts,
]);
}
@@ -128,7 +130,7 @@ class SearchController extends Controller
}
/**
* Search siblings items in the system.
* Search sibling items in the system.
*/
public function searchSiblings(Request $request, SiblingFetcher $siblingFetcher)
{

View File

@@ -126,7 +126,7 @@ class SearchIndex
$termMap = $this->textToTermCountMap($text);
foreach ($termMap as $term => $count) {
$termMap[$term] = floor($count * $scoreAdjustment);
$termMap[$term] = intval($count * $scoreAdjustment);
}
return $termMap;

View File

@@ -91,7 +91,7 @@ class SearchResultsFormatter
$offset = 0;
$term = mb_strtolower($term);
$pos = mb_strpos($text, $term, $offset);
while ($pos !== false) {
while ($pos !== false && count($matchRefs) < 25) {
$end = $pos + mb_strlen($term);
$matchRefs[$pos] = $end;
$offset = $end;

View File

@@ -4,16 +4,15 @@ namespace BookStack\Search;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\EntityHydrator;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Search\Options\TagSearchOption;
use BookStack\Users\Models\User;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@@ -22,7 +21,7 @@ use WeakMap;
class SearchRunner
{
/**
* Retain a cache of score adjusted terms for specific search options.
* Retain a cache of score-adjusted terms for specific search options.
*/
protected WeakMap $termAdjustmentCache;
@@ -30,16 +29,15 @@ class SearchRunner
protected EntityProvider $entityProvider,
protected PermissionApplicator $permissions,
protected EntityQueries $entityQueries,
protected EntityHydrator $entityHydrator,
) {
$this->termAdjustmentCache = new WeakMap();
}
/**
* Search all entities in the system.
* The provided count is for each entity to search,
* Total returned could be larger and not guaranteed.
*
* @return array{total: int, count: int, has_more: bool, results: Collection<Entity>}
* @return array{total: int, results: Collection<Entity>}
*/
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array
{
@@ -53,32 +51,13 @@ class SearchRunner
$entityTypesToSearch = explode('|', $filterMap['type']);
}
$results = collect();
$total = 0;
$hasMore = false;
foreach ($entityTypesToSearch as $entityType) {
if (!in_array($entityType, $entityTypes)) {
continue;
}
$searchQuery = $this->buildQuery($searchOpts, $entityType);
$entityTotal = $searchQuery->count();
$searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityType, $page, $count);
if ($entityTotal > ($page * $count)) {
$hasMore = true;
}
$total += $entityTotal;
$results = $results->merge($searchResults);
}
$searchQuery = $this->buildQuery($searchOpts, $entityTypesToSearch);
$total = $searchQuery->count();
$results = $this->getPageOfDataFromQuery($searchQuery, $page, $count);
return [
'total' => $total,
'count' => count($results),
'has_more' => $hasMore,
'results' => $results->sortByDesc('score')->values(),
'results' => $results->values(),
];
}
@@ -92,17 +71,10 @@ class SearchRunner
$filterMap = $opts->filters->toValueMap();
$entityTypesToSearch = isset($filterMap['type']) ? explode('|', $filterMap['type']) : $entityTypes;
$results = collect();
foreach ($entityTypesToSearch as $entityType) {
if (!in_array($entityType, $entityTypes)) {
continue;
}
$filteredTypes = array_intersect($entityTypesToSearch, $entityTypes);
$query = $this->buildQuery($opts, $filteredTypes)->where('book_id', '=', $bookId);
$search = $this->buildQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
$results = $results->merge($search);
}
return $results->sortByDesc('score')->take(20);
return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score');
}
/**
@@ -111,54 +83,45 @@ class SearchRunner
public function searchChapter(int $chapterId, string $searchString): Collection
{
$opts = SearchOptions::fromString($searchString);
$pages = $this->buildQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
$query = $this->buildQuery($opts, ['page'])->where('chapter_id', '=', $chapterId);
return $pages->sortByDesc('score');
return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score');
}
/**
* Get a page of result data from the given query based on the provided page parameters.
*/
protected function getPageOfDataFromQuery(EloquentBuilder $query, string $entityType, int $page = 1, int $count = 20): EloquentCollection
protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int $count): Collection
{
$relations = ['tags'];
if ($entityType === 'page' || $entityType === 'chapter') {
$relations['book'] = function (BelongsTo $query) {
$query->scopes('visible');
};
}
if ($entityType === 'page') {
$relations['chapter'] = function (BelongsTo $query) {
$query->scopes('visible');
};
}
return $query->clone()
->with(array_filter($relations))
$entities = $query->clone()
->skip(($page - 1) * $count)
->take($count)
->get();
$hydrated = $this->entityHydrator->hydrate($entities->all(), true, true);
return collect($hydrated);
}
/**
* Create a search query for an entity.
* @param string[] $entityTypes
*/
protected function buildQuery(SearchOptions $searchOpts, string $entityType): EloquentBuilder
protected function buildQuery(SearchOptions $searchOpts, array $entityTypes): EloquentBuilder
{
$entityModelInstance = $this->entityProvider->get($entityType);
$entityQuery = $this->entityQueries->visibleForList($entityType);
$entityQuery = $this->entityQueries->visibleForList()
->whereIn('type', $entityTypes);
// Handle normal search terms
$this->applyTermSearch($entityQuery, $searchOpts, $entityType);
$this->applyTermSearch($entityQuery, $searchOpts, $entityTypes);
// Handle exact term matching
foreach ($searchOpts->exacts->all() as $exact) {
$filter = function (EloquentBuilder $query) use ($exact, $entityModelInstance) {
$filter = function (EloquentBuilder $query) use ($exact) {
$inputTerm = str_replace('\\', '\\\\', $exact->value);
$query->where('name', 'like', '%' . $inputTerm . '%')
->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%');
->orWhere('description', 'like', '%' . $inputTerm . '%')
->orWhere('text', 'like', '%' . $inputTerm . '%');
};
$exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter);
@@ -173,7 +136,7 @@ class SearchRunner
foreach ($searchOpts->filters->all() as $filterOption) {
$functionName = Str::camel('filter_' . $filterOption->getKey());
if (method_exists($this, $functionName)) {
$this->$functionName($entityQuery, $entityModelInstance, $filterOption->value, $filterOption->negated);
$this->$functionName($entityQuery, $filterOption->value, $filterOption->negated);
}
}
@@ -183,7 +146,7 @@ class SearchRunner
/**
* For the given search query, apply the queries for handling the regular search terms.
*/
protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, string $entityType): void
protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, array $entityTypes): void
{
$terms = $options->searches->toValueArray();
if (count($terms) === 0) {
@@ -200,8 +163,6 @@ class SearchRunner
]);
$subQuery->addBinding($scoreSelect['bindings'], 'select');
$subQuery->where('entity_type', '=', $entityType);
$subQuery->where(function (Builder $query) use ($terms) {
foreach ($terms as $inputTerm) {
$escapedTerm = str_replace('\\', '\\\\', $inputTerm);
@@ -210,7 +171,10 @@ class SearchRunner
});
$subQuery->groupBy('entity_type', 'entity_id');
$entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id');
$entityQuery->joinSub($subQuery, 's', function (JoinClause $join) {
$join->on('s.entity_id', '=', 'entities.id')
->on('s.entity_type', '=', 'entities.type');
});
$entityQuery->addSelect('s.score');
$entityQuery->orderBy('score', 'desc');
}
@@ -338,7 +302,7 @@ class SearchRunner
$option->negated ? $query->whereDoesntHave('tags', $filter) : $query->whereHas('tags', $filter);
}
protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string $column, string $operator, mixed $value): void
protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string|callable $column, string|null $operator, mixed $value): void
{
if ($negated) {
$query->whereNot($column, $operator, $value);
@@ -350,31 +314,31 @@ class SearchRunner
/**
* Custom entity search filters.
*/
protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
protected function filterUpdatedAfter(EloquentBuilder $query, string $input, bool $negated): void
{
$date = date_create($input);
$this->applyNegatableWhere($query, $negated, 'updated_at', '>=', $date);
}
protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
protected function filterUpdatedBefore(EloquentBuilder $query, string $input, bool $negated): void
{
$date = date_create($input);
$this->applyNegatableWhere($query, $negated, 'updated_at', '<', $date);
}
protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
protected function filterCreatedAfter(EloquentBuilder $query, string $input, bool $negated): void
{
$date = date_create($input);
$this->applyNegatableWhere($query, $negated, 'created_at', '>=', $date);
}
protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated)
protected function filterCreatedBefore(EloquentBuilder $query, string $input, bool $negated)
{
$date = date_create($input);
$this->applyNegatableWhere($query, $negated, 'created_at', '<', $date);
}
protected function filterCreatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
protected function filterCreatedBy(EloquentBuilder $query, string $input, bool $negated)
{
$userSlug = $input === 'me' ? user()->slug : trim($input);
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
@@ -383,7 +347,7 @@ class SearchRunner
}
}
protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
protected function filterUpdatedBy(EloquentBuilder $query, string $input, bool $negated)
{
$userSlug = $input === 'me' ? user()->slug : trim($input);
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
@@ -392,7 +356,7 @@ class SearchRunner
}
}
protected function filterOwnedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
protected function filterOwnedBy(EloquentBuilder $query, string $input, bool $negated)
{
$userSlug = $input === 'me' ? user()->slug : trim($input);
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
@@ -401,27 +365,30 @@ class SearchRunner
}
}
protected function filterInName(EloquentBuilder $query, Entity $model, string $input, bool $negated)
protected function filterInName(EloquentBuilder $query, string $input, bool $negated)
{
$this->applyNegatableWhere($query, $negated, 'name', 'like', '%' . $input . '%');
}
protected function filterInTitle(EloquentBuilder $query, Entity $model, string $input, bool $negated)
protected function filterInTitle(EloquentBuilder $query, string $input, bool $negated)
{
$this->filterInName($query, $model, $input, $negated);
$this->filterInName($query, $input, $negated);
}
protected function filterInBody(EloquentBuilder $query, Entity $model, string $input, bool $negated)
protected function filterInBody(EloquentBuilder $query, string $input, bool $negated)
{
$this->applyNegatableWhere($query, $negated, $model->textField, 'like', '%' . $input . '%');
$this->applyNegatableWhere($query, $negated, function (EloquentBuilder $query) use ($input) {
$query->where('description', 'like', '%' . $input . '%')
->orWhere('text', 'like', '%' . $input . '%');
}, null, null);
}
protected function filterIsRestricted(EloquentBuilder $query, Entity $model, string $input, bool $negated)
protected function filterIsRestricted(EloquentBuilder $query, string $input, bool $negated)
{
$negated ? $query->whereDoesntHave('permissions') : $query->whereHas('permissions');
}
protected function filterViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
protected function filterViewedByMe(EloquentBuilder $query, string $input, bool $negated)
{
$filter = function ($query) {
$query->where('user_id', '=', user()->id);
@@ -430,7 +397,7 @@ class SearchRunner
$negated ? $query->whereDoesntHave('views', $filter) : $query->whereHas('views', $filter);
}
protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
protected function filterNotViewedByMe(EloquentBuilder $query, string $input, bool $negated)
{
$filter = function ($query) {
$query->where('user_id', '=', user()->id);
@@ -439,31 +406,30 @@ class SearchRunner
$negated ? $query->whereHas('views', $filter) : $query->whereDoesntHave('views', $filter);
}
protected function filterIsTemplate(EloquentBuilder $query, Entity $model, string $input, bool $negated)
protected function filterIsTemplate(EloquentBuilder $query, string $input, bool $negated)
{
if ($model instanceof Page) {
$this->applyNegatableWhere($query, $negated, 'template', '=', true);
}
$this->applyNegatableWhere($query, $negated, 'template', '=', true);
}
protected function filterSortBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
protected function filterSortBy(EloquentBuilder $query, string $input, bool $negated)
{
$functionName = Str::camel('sort_by_' . $input);
if (method_exists($this, $functionName)) {
$this->$functionName($query, $model, $negated);
$this->$functionName($query, $negated);
}
}
/**
* Sorting filter options.
*/
protected function sortByLastCommented(EloquentBuilder $query, Entity $model, bool $negated)
protected function sortByLastCommented(EloquentBuilder $query, bool $negated)
{
$commentsTable = DB::getTablePrefix() . 'comments';
$morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
$commentQuery = DB::raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments');
$commentQuery = DB::raw('(SELECT c1.commentable_id, c1.commentable_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.commentable_id = c2.commentable_id AND c1.commentable_type = c2.commentable_type AND c1.created_at < c2.created_at) WHERE c2.created_at IS NULL) as comments');
$query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.entity_id'))
->orderBy('last_commented', $negated ? 'asc' : 'desc');
$query->join($commentQuery, function (JoinClause $join) {
$join->on('entities.id', '=', 'comments.commentable_id')
->on('entities.type', '=', 'comments.commentable_type');
})->orderBy('last_commented', $negated ? 'asc' : 'desc');
}
}

View File

@@ -33,22 +33,22 @@ class BookSorter
*/
public function runBookAutoSort(Book $book): void
{
$set = $book->sortRule;
if (!$set) {
$rule = $book->sortRule()->first();
if (!($rule instanceof SortRule)) {
return;
}
$sortFunctions = array_map(function (SortRuleOperation $op) {
return $op->getSortFunction();
}, $set->getOperations());
}, $rule->getOperations());
$chapters = $book->chapters()
->with('pages:id,name,priority,created_at,updated_at,chapter_id')
->with('pages:id,name,book_id,chapter_id,priority,created_at,updated_at')
->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
/** @var (Chapter|Book)[] $topItems */
$topItems = [
...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
...$book->directPages()->get(['id', 'book_id', 'name', 'priority', 'created_at', 'updated_at']),
...$chapters,
];
@@ -155,11 +155,12 @@ class BookSorter
// Action the required changes
if ($bookChanged) {
$model->changeBook($newBook->id);
$model = $model->changeBook($newBook->id);
}
if ($model instanceof Page && $chapterChanged) {
$model->chapter_id = $newChapter->id ?? 0;
$model->unsetRelation('chapter');
}
if ($priorityChanged) {

View File

@@ -50,7 +50,7 @@ class SortRule extends Model implements Loggable
public function books(): HasMany
{
return $this->hasMany(Book::class);
return $this->hasMany(Book::class, 'entity_container_data.sort_rule_id', 'id');
}
public static function allByName(): Collection

View File

@@ -3,6 +3,7 @@
namespace BookStack\Sorting;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Models\EntityContainerData;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use Illuminate\Http\Request;
@@ -88,7 +89,9 @@ class SortRuleController extends Controller
if ($booksAssigned > 0) {
if ($confirmed) {
$rule->books()->update(['sort_rule_id' => null]);
EntityContainerData::query()
->where('sort_rule_id', $rule->id)
->update(['sort_rule_id' => null]);
} else {
$warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]);
}

View File

@@ -2,6 +2,7 @@
namespace BookStack\Uploads\Controllers;
use BookStack\Entities\EntityExistsRule;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\FileUploadException;
use BookStack\Http\ApiController;
@@ -173,13 +174,13 @@ class AttachmentApiController extends ApiController
return [
'create' => [
'name' => ['required', 'string', 'min:1', 'max:255'],
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')],
'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
'link' => ['required_without:file', 'string', 'min:1', 'max:2000', 'safe_url'],
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'uploaded_to' => ['integer', 'exists:pages,id'],
'uploaded_to' => ['integer', new EntityExistsRule('page')],
'file' => $this->attachmentService->getFileValidationRules(),
'link' => ['string', 'min:1', 'max:2000', 'safe_url'],
],

View File

@@ -2,6 +2,7 @@
namespace BookStack\Uploads\Controllers;
use BookStack\Entities\EntityExistsRule;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\FileUploadException;
@@ -34,7 +35,7 @@ class AttachmentController extends Controller
public function upload(Request $request)
{
$this->validate($request, [
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')],
'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
]);
@@ -144,7 +145,7 @@ class AttachmentController extends Controller
try {
$this->validate($request, [
'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'attachment_link_uploaded_to' => ['required', 'integer', new EntityExistsRule('page')],
'attachment_link_name' => ['required', 'string', 'min:1', 'max:255'],
'attachment_link_url' => ['required', 'string', 'min:1', 'max:2000', 'safe_url'],
]);

View File

@@ -3,11 +3,13 @@
namespace BookStack\Uploads\Controllers;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\NotFoundException;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageResizer;
use BookStack\Uploads\ImageService;
use Illuminate\Http\Request;
class ImageGalleryApiController extends ApiController
@@ -20,6 +22,7 @@ class ImageGalleryApiController extends ApiController
protected ImageRepo $imageRepo,
protected ImageResizer $imageResizer,
protected PageQueries $pageQueries,
protected ImageService $imageService,
) {
}
@@ -32,6 +35,9 @@ class ImageGalleryApiController extends ApiController
'image' => ['required', 'file', ...$this->getImageValidationRules()],
'name' => ['string', 'max:180'],
],
'readDataForUrl' => [
'url' => ['required', 'string', 'url'],
],
'update' => [
'name' => ['string', 'max:180'],
'image' => ['file', ...$this->getImageValidationRules()],
@@ -85,7 +91,8 @@ class ImageGalleryApiController extends ApiController
* The "thumbs" response property contains links to scaled variants that BookStack may use in its UI.
* The "content" response property provides HTML and Markdown content, in the format that BookStack
* would typically use by default to add the image in page content, as a convenience.
* Actual image file data is not provided but can be fetched via the "url" response property.
* Actual image file data is not provided but can be fetched via the "url" response property or by
* using the "read-data" endpoint.
*/
public function read(string $id)
{
@@ -94,6 +101,37 @@ class ImageGalleryApiController extends ApiController
return response()->json($this->formatForSingleResponse($image));
}
/**
* Read the image file data for a single image in the system.
* The returned response will be a stream of image data instead of a JSON response.
*/
public function readData(string $id)
{
$image = Image::query()->scopes(['visible'])->findOrFail($id);
return $this->imageService->streamImageFromStorageResponse('gallery', $image->path);
}
/**
* Read the image file data for a single image in the system, using the provided URL
* to identify the image instead of its ID, which is provided as a "URL" query parameter.
* The returned response will be a stream of image data instead of a JSON response.
*/
public function readDataForUrl(Request $request)
{
$data = $this->validate($request, $this->rules()['readDataForUrl']);
$basePath = url('/uploads/images/');
$imagePath = str_replace($basePath, '', $data['url']);
if (!$this->imageService->pathAccessible($imagePath)) {
throw (new NotFoundException(trans('errors.image_not_found')))
->setSubtitle(trans('errors.image_not_found_subtitle'))
->setDetails(trans('errors.image_not_found_details'));
}
return $this->imageService->streamImageFromStorageResponse('gallery', $imagePath);
}
/**
* Update the details of an existing image in the system.
* Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request if providing a

View File

@@ -13,14 +13,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property string $name
* @property string $url
* @property string $path
* @property string $type
* @property int $uploaded_to
* @property int $created_by
* @property int $updated_by
* @property int $id
* @property string $name
* @property string $url
* @property string $path
* @property string $type
* @property int|null $uploaded_to
* @property int $created_by
* @property int $updated_by
*/
class Image extends Model implements OwnableInterface
{
@@ -42,7 +42,9 @@ class Image extends Model implements OwnableInterface
*/
public function scopeVisible(Builder $query): Builder
{
return app()->make(PermissionApplicator::class)->restrictPageRelationQuery($query, 'images', 'uploaded_to');
return app()->make(PermissionApplicator::class)
->restrictPageRelationQuery($query, 'images', 'uploaded_to')
->whereIn('type', ['gallery', 'drawio']);
}
/**

View File

@@ -148,7 +148,7 @@ class ImageService
}
/**
* Destroy an image along with its revisions, thumbnails and remaining folders.
* Destroy an image along with its revisions, thumbnails, and remaining folders.
*
* @throws Exception
*/
@@ -184,7 +184,7 @@ class ImageService
/** @var Image $image */
foreach ($images as $image) {
$searchQuery = '%' . basename($image->path) . '%';
$inPage = DB::table('pages')
$inPage = DB::table('entity_page_data')
->where('html', 'like', $searchQuery)->count() > 0;
$inRevision = false;
@@ -252,16 +252,59 @@ class ImageService
{
$disk = $this->storage->getDisk('gallery');
return $disk->usingSecureImages() && $this->pathAccessible($imagePath);
}
/**
* Check if the given path exists and is accessible depending on the current settings.
*/
public function pathAccessible(string $imagePath): bool
{
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
return false;
}
// Check local_secure is active
return $disk->usingSecureImages()
// Check the image file exists
&& $disk->exists($imagePath)
// Check the file is likely an image file
&& str_starts_with($disk->mimeType($imagePath), 'image/');
if ($this->blockedBySecureImages()) {
return false;
}
return $this->imageFileExists($imagePath, 'gallery');
}
/**
* Check if the given image should be accessible to the current user.
*/
public function imageAccessible(Image $image): bool
{
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImage($image)) {
return false;
}
if ($this->blockedBySecureImages()) {
return false;
}
return $this->imageFileExists($image->path, $image->type);
}
/**
* Check if the current user should be blocked from accessing images based on if secure images are enabled
* and if public access is enabled for the application.
*/
protected function blockedBySecureImages(): bool
{
$enforced = $this->storage->usingSecureImages() && !setting('app-public');
return $enforced && user()->isGuest();
}
/**
* Check if the given image path exists for the given image type and that it is likely an image file.
*/
protected function imageFileExists(string $imagePath, string $imageType): bool
{
$disk = $this->storage->getDisk($imageType);
return $disk->exists($imagePath) && str_starts_with($disk->mimeType($imagePath), 'image/');
}
/**
@@ -290,6 +333,11 @@ class ImageService
return false;
}
return $this->checkUserHasAccessToRelationOfImage($image);
}
protected function checkUserHasAccessToRelationOfImage(Image $image): bool
{
$imageType = $image->type;
// Allow user or system (logo) images

View File

@@ -34,6 +34,15 @@ class ImageStorage
return config('filesystems.images') === 'local_secure_restricted';
}
/**
* Check if "local secure" (Fetched behind auth, either with or without permissions enforced)
* is currently active in the instance.
*/
public function usingSecureImages(): bool
{
return config('filesystems.images') === 'local_secure' || $this->usingSecureRestrictedImages();
}
/**
* Clean up an image file name to be both URL and storage safe.
*/
@@ -65,7 +74,7 @@ class ImageStorage
return 'local';
}
// Rename local_secure options to get our image specific storage driver which
// Rename local_secure options to get our image-specific storage driver, which
// is scoped to the relevant image directories.
if ($localSecureInUse) {
return 'local_secure_images';

View File

@@ -2,6 +2,7 @@
namespace BookStack\Users\Controllers;
use BookStack\Entities\EntityExistsRule;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;

View File

@@ -31,8 +31,6 @@ use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
/**
* Class User.
*
* @property int $id
* @property string $name
* @property string $slug

View File

@@ -5,13 +5,13 @@ namespace BookStack\Users;
use BookStack\Access\UserInviteException;
use BookStack\Access\UserInviteService;
use BookStack\Activity\ActivityType;
use BookStack\Entities\EntityProvider;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Facades\Activity;
use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\Role;
use BookStack\Users\Models\User;
use DB;
use Exception;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
@@ -25,7 +25,6 @@ class UserRepo
) {
}
/**
* Get a user by their email address.
*/
@@ -159,15 +158,12 @@ class UserRepo
*
* @throws Exception
*/
public function destroy(User $user, ?int $newOwnerId = null)
public function destroy(User $user, ?int $newOwnerId = null): void
{
$this->ensureDeletable($user);
$user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
$user->watches()->delete();
$this->removeUserDependantRelations($user);
$this->nullifyUserNonDependantRelations($user);
$user->delete();
// Delete user profile images
@@ -176,16 +172,52 @@ class UserRepo
// Delete related activities
setting()->deleteUserSettings($user->id);
// Migrate or nullify ownership
$newOwner = null;
if (!empty($newOwnerId)) {
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {
$this->migrateOwnership($user, $newOwner);
}
}
$this->migrateOwnership($user, $newOwner);
Activity::add(ActivityType::USER_DELETE, $user);
}
protected function removeUserDependantRelations(User $user): void
{
$user->apiTokens()->delete();
$user->socialAccounts()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
$user->watches()->delete();
$tables = ['email_confirmations', 'user_invites', 'views'];
foreach ($tables as $table) {
DB::table($table)->where('user_id', '=', $user->id)->delete();
}
}
protected function nullifyUserNonDependantRelations(User $user): void
{
$toNullify = [
'attachments' => ['created_by', 'updated_by'],
'comments' => ['created_by', 'updated_by'],
'deletions' => ['deleted_by'],
'entities' => ['created_by', 'updated_by'],
'images' => ['created_by', 'updated_by'],
'imports' => ['created_by'],
'joint_permissions' => ['owner_id'],
'page_revisions' => ['created_by'],
'sessions' => ['user_id'],
];
foreach ($toNullify as $table => $columns) {
foreach ($columns as $column) {
DB::table($table)
->where($column, '=', $user->id)
->update([$column => null]);
}
}
}
/**
* @throws NotifyException
*/
@@ -203,13 +235,12 @@ class UserRepo
/**
* Migrate ownership of items in the system from one user to another.
*/
protected function migrateOwnership(User $fromUser, User $toUser)
protected function migrateOwnership(User $fromUser, User|null $toUser): void
{
$entities = (new EntityProvider())->all();
foreach ($entities as $instance) {
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
->update(['owned_by' => $toUser->id]);
}
$newOwnerValue = $toUser ? $toUser->id : null;
DB::table('entities')
->where('owned_by', '=', $fromUser->id)
->update(['owned_by' => $newOwnerValue]);
}
/**
@@ -247,7 +278,7 @@ class UserRepo
*
* @throws UserUpdateException
*/
protected function setUserRoles(User $user, array $roles)
protected function setUserRoles(User $user, array $roles): void
{
$roles = array_filter(array_values($roles));
@@ -260,7 +291,7 @@ class UserRepo
/**
* Check if the given user is the last admin and their new roles no longer
* contains the admin role.
* contain the admin role.
*/
protected function demotingLastAdmin(User $user, array $newRoles): bool
{

View File

@@ -93,6 +93,7 @@
"@php artisan view:clear"
],
"refresh-test-database": [
"@putenv APP_TIMEZONE=UTC",
"@php artisan migrate:refresh --database=mysql_testing",
"@php artisan db:seed --class=DummyContentSeeder --database=mysql_testing"
]

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