Compare commits

...

214 Commits

Author SHA1 Message Date
Dan Brown
fa566f156a
Updated translator & dependency attribution before release v25.02.2
Some checks failed
analyse-php / build (push) Has been cancelled
lint-js / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-js / build (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-migrations / build (8.4) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
test-php / build (8.4) (push) Has been cancelled
2025-04-02 17:30:43 +01:00
Dan Brown
78a0a2f519
Merge pull request #5558 from BookStackApp/lexical_round3
Lexical Fixes: Round 3
2025-04-02 17:23:38 +01:00
Dan Brown
42cbd6adef
Updated translations with latest Crowdin changes (#5537) 2025-04-02 17:19:34 +01:00
Dan Brown
6117349893
Deps: Updated composer packages 2025-04-02 15:30:31 +01:00
Dan Brown
1256320c72
Merge branch 'bernardo-campos/development' into development 2025-04-02 15:18:31 +01:00
Dan Brown
1ba0d26fdd
Sort Rules: Updated name comparison to not ignore non-ascii chars
Related to #5550 and #5542
2025-04-02 15:17:17 +01:00
Dan Brown
802f69cf35
Comments: Fixed missing comment timestamps
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-migrations / build (8.4) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
test-php / build (8.4) (push) Has been cancelled
Due to deleted code during Laravel 11 upgrade.
Added test to cover.
Closes #5555
2025-03-30 17:36:48 +01:00
Dan Brown
bb44334224
Lexical: Added tests to cover recent changes
Some checks failed
test-js / build (push) Has been cancelled
Also updated list tests to new test process.
2025-03-28 18:29:00 +00:00
Dan Brown
9bfcadd95f
Lexical: Improved navigation around images/media
- Added specific handling to move/insert-up/down on arrow press.
- Prevented resize overlay from interrupting image node focus.
2025-03-28 14:30:03 +00:00
Dan Brown
62c8eb3357
Lexical: Made list selections & intendting more reliable
- Added handling to not include parent of top-most list range selection
  so that it's not also changed while not visually part of the
  selection range.
- Fixed issue where list items could be left over after unnesting, due
  to empty checks/removals occuring before all child handling.
- Added node sorting, applied to list items during nest operations so
  that selection range remains reliable.
2025-03-27 17:49:48 +00:00
Dan Brown
c03e44124a
Lexical: Fixed task list parsing
Updated list DOM parsing to properly consider task list format set by
other MD/WYSIWYG editors.
2025-03-27 14:56:32 +00:00
Dan Brown
5c6671b3bf
Lexical: Fixed issues with content not saving
Found that saving via Ctrl+Enter did not save as logic to load editor
output into form was bypassed, which this fixes by ensuring submit
events are raised during for this shortcut.

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

For #5454
2025-03-16 12:00:54 +00:00
Dan Brown
4f5ad171ac
Config: Updated DB host to handle ipv6
Some checks are pending
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-migrations / build (8.4) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
test-php / build (8.4) (push) Waiting to run
Can be set via the square bracket format.
For #5464
2025-03-15 20:32:57 +00:00
Dan Brown
94b1cffa2d
System CLI: Updated with new version
As per https://codeberg.org/bookstack/system-cli/pulls/21
dev/checksums folder added to support this new system.

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

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

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

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

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

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

For #3904
2025-01-13 14:34:44 +00:00
Dan Brown
b9751807e7
Merge pull request #5400 from BookStackApp/laravel11
Some checks failed
analyse-php / build (push) Has been cancelled
lint-js / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-js / build (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-migrations / build (8.4) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
test-php / build (8.4) (push) Has been cancelled
Laravel 11 Upgrade
2025-01-13 13:27:32 +00:00
Dan Brown
ee88832f1a
Updated translations with latest Crowdin changes (#5399) 2025-01-13 13:26:04 +00:00
Dan Brown
dbda82ef92
Framework: Re-add updated patched symfony-mailer
Some checks failed
analyse-php / build (push) Has been cancelled
lint-js / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-js / build (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-migrations / build (8.4) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
test-php / build (8.4) (push) Has been cancelled
https://github.com/ssddanbrown/symfony-mailer/commit/e9de8dccd76a63fc23475016e6574da6f5f12a2
2025-01-11 15:05:10 +00:00
Dan Brown
ad8bc5fe21
Framework: Updated phpunit to 11, updated migration test php versions 2025-01-11 13:50:01 +00:00
Dan Brown
5bf75786c6
Framework: Fixed Laravel 11 upgrade test issues, updated phpstan
- Fixed failing tests due to Laravel 11 changes
- Updated phpstan to 3.x branch
- Removed some seemingly redundant comment code, which was triggering
  phpstan.
2025-01-11 13:22:49 +00:00
Dan Brown
cf9ccfcd5b
Framework: Performed Laravel 11 upgrade guide steps
Some checks are pending
analyse-php / build (push) Waiting to run
lint-js / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-js / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-migrations / build (8.4) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
test-php / build (8.4) (push) Waiting to run
Performed a little code cleanups when observed along the way.
Tested not yet ran.
2025-01-11 11:14:49 +00:00
Dan Brown
5116d83d38
PHP: Updated min version to 8.2
PHPStan config not yet compatible, but should work after moving to Laravel
11, which would allow using larastan 3.x.
2025-01-09 16:46:13 +00:00
Dan Brown
33b46882f3
Updated translations with latest Crowdin changes (#5370)
Some checks failed
analyse-php / build (push) Has been cancelled
lint-js / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-js / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-migrations / build (8.4) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
test-php / build (8.4) (push) Has been cancelled
2025-01-04 21:46:35 +00:00
Dan Brown
9a5c287470
Deps: Updated composer packages 2025-01-04 21:45:36 +00:00
Dan Brown
6effc6d262
Merge pull request #5379 from BookStackApp/better_cleanup
Export limits and cleanup
2025-01-04 21:05:45 +00:00
Dan Brown
ff6c5aaecb
Markdown Editor: Fixed scroll jump on image upload
For #5384
2025-01-04 21:01:28 +00:00
Dan Brown
1ff2826678
Exports: Added rate limits for UI exports
Some checks failed
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
test-php / build (8.4) (push) Has been cancelled
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-migrations / build (8.4) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
Just as a measure to prevent potential abuse of these potentially
longer-running endpoints.
Adds test to cover for ZIP exports, but applied to all formats.
2025-01-01 15:42:59 +00:00
Dan Brown
7e31725d48
Exports: Improved PDF command temp file cleanup 2025-01-01 15:19:11 +00:00
Dan Brown
6d7ff59a89
ZIP Exports: Improved temp file tracking & clean-up 2024-12-31 15:13:50 +00:00
Dan Brown
980a684b14
Updated translator & dependency attribution before release v24.12
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-migrations / build (8.4) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
test-php / build (8.4) (push) Has been cancelled
2024-12-23 11:53:35 +00:00
Dan Brown
d56eea9279
Locales: Updated locale list with new languages 2024-12-23 11:27:58 +00:00
Dan Brown
2be504e0d2
Updated translations with latest Crowdin changes (#5345) 2024-12-23 11:23:44 +00:00
Dan Brown
c84d999456
ZIP Exports: Prevent book child page drafts from being included
Some checks are pending
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-migrations / build (8.4) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
test-php / build (8.4) (push) Waiting to run
Added test to cover
2024-12-22 12:43:26 +00:00
Dan Brown
01825ddb93
Dependancies: Bumped up composer dep versions
Some checks are pending
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-migrations / build (8.4) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
test-php / build (8.4) (push) Waiting to run
2024-12-21 15:48:46 +00:00
Dan Brown
1f88bc2a59
Merge pull request #5365 from BookStackApp/lexical_fixes
Some checks failed
analyse-php / build (push) Has been cancelled
lint-js / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-js / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-migrations / build (8.4) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
test-php / build (8.4) (push) Has been cancelled
Range of fixes/updates for the new Lexical based editor
2024-12-20 14:51:57 +00:00
Dan Brown
ebe2ca7faf
Lexical: Added about button/view
Some checks failed
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
test-php / build (8.4) (push) Has been cancelled
analyse-php / build (push) Has been cancelled
lint-js / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-js / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-migrations / build (8.4) (push) Has been cancelled
Re-used existing route and moved tinymce help to its own different
route. Added test to cover.
Added new external-content block to support in editor UI.
2024-12-17 22:40:28 +00:00
Dan Brown
f4005a139b
Lexical: Adjusted handling of child/sibling list items on nesting
Sibling/child items will now remain at the same visual level during
nesting/un-nested, so only the selected item level is visually altered.

Also added new model-based editor content matching system for tests.
2024-12-17 18:07:46 +00:00
Dan Brown
fca8f928a3
Lexical: Aligned new empty item behaviour for nested lists
Some checks are pending
test-js / build (push) Waiting to run
- Makes enter on empty nested list item un-nest instead of just creating
  new list items.
- Also updated existing lists tests to use newer helper setup.
2024-12-17 16:52:14 +00:00
Dan Brown
ace8af077d
Lexical: Improved list tab handling, Improved test utils
- Made tab work on empty list items
- Improved select preservation on single list item tab
- Altered test context creation for more standard testing
2024-12-17 14:44:10 +00:00
Dan Brown
e50cd33277
Lexical: Added testing for some added shortcuts
Some checks failed
test-js / build (push) Waiting to run
lint-js / build (push) Has been cancelled
Also:
- Added svg loading support (dummy stub) for jest.
- Updated headless test case due to node changes.
- Split out editor change detected to where appropriate.
- Added functions to help with testing, like mocking our context.
2024-12-16 16:27:44 +00:00
Dan Brown
8486775edf
Lexical: Added mulitple methods to escape details block
Enter on empty last line, or down on last empty line, will focus on the
next node after details, or created a new paragraph to focus on if
needed.
2024-12-16 14:30:06 +00:00
Dan Brown
5887322178
Lexical: Added details toolbar
Some checks are pending
test-js / build (push) Waiting to run
Includes unwrap and toggle open actions.
2024-12-15 18:13:49 +00:00
Dan Brown
3f86937f74
Lexical: Made summary part of details node
To provide more control of the summary as part of details.
To support, added a way to ignore elements during import DOM, allowing
up to read summaries when parsing details without duplicate nodes
involved.
2024-12-15 17:12:54 +00:00
Dan Brown
2f119d3033
Lexical: Adjusted modals and content area for mobile sizes
Some checks are pending
test-js / build (push) Waiting to run
2024-12-15 15:29:00 +00:00
Dan Brown
5f07f31c9f
Lexical: Added mobile toolbar support
Adds dynamic and fixed (out of DOM order) positioning with location
adjustment depending on space.
Also adds smarter hiding to prevent disappearing when mouse leaves but
within the same space as the toggle.
2024-12-15 14:03:08 +00:00
Dan Brown
a71aa241ad
Lexical: Added dark mode styles, fixed autolink range 2024-12-14 15:17:33 +00:00
Dan Brown
97b201f61f
Lexical: Added auto links on enter/space 2024-12-14 12:35:13 +00:00
Dan Brown
a8ef820443
Users: Hid lanuage preference for guest user
Some checks failed
analyse-php / build (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-migrations / build (8.4) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
test-php / build (8.4) (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
Hiding since it's not really used, and may mislead on how to set default
app language (which should be done via env options).
Updated test to cover.

For #5356
2024-12-13 15:19:28 +00:00
Dan Brown
7e1a8e5ec6
API: Added cover to book/shelf list endpoints
Some checks failed
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-migrations / build (8.4) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
test-php / build (8.4) (push) Waiting to run
test-js / build (push) Has been cancelled
lint-js / build (push) Has been cancelled
Aligns with what we provide in the UI.
Added/updated tests to cover, and updated API examples.

For 5180.
2024-12-13 14:21:04 +00:00
Dan Brown
19ee1c9be7
Notifications: Logged errors and prevented them blocking user
Some checks are pending
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-migrations / build (8.4) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
test-php / build (8.4) (push) Waiting to run
Failed notification sends could block the user action, whereas it's
probably more important that the user action takes places uninteruupted
than showing an error screen for the user to debug.
Logs notification errors so issues can still be debugged by admins.

Closes #5315
2024-12-12 21:47:39 +00:00
Dan Brown
fcf0bf79a9
Attachments: Hid edit/delete controls where lacking permission
Some checks failed
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-migrations / build (8.4) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
test-php / build (8.4) (push) Waiting to run
lint-js / build (push) Has been cancelled
test-js / build (push) Has been cancelled
Added test to cover.
Also migrated related ajax-delete-row component to ts.

For #5323
2024-12-11 20:38:30 +00:00
Dan Brown
0ece664475
CI: Added php8.4 to CI suites, bumped action/os versions 2024-12-11 18:50:10 +00:00
Dan Brown
509af2463d
Search Index: Fixed SQL error when indexing large pages
Due to hitting statement placeholder limits (typically 65k)
when inserting index terms for single page.

Added test to cover.
Also added skipped tests for tests we don't always want to run.
For #5322
2024-12-11 15:55:19 +00:00
Dan Brown
5632fef621
Auth: Added specific guards against guest account login
Hardened things to enforce the intent that the guest account should not
be used for logins.
Currently this would not be allowed due to empty set password, and no
password fields on user edit forms, but an error could occur if the
login was attempted.

This adds:
- Handling to show normal invalid user warning on login instead of a
  hash check error.
- Prevention of guest user via main login route, in the event that
  inventive workarounds would be used by admins to set a password for
  this account.
- Test for guest user login.
2024-12-11 14:22:48 +00:00
Dan Brown
8ec26e8083
SASS: Updated to use modules and address deprecations
Some checks failed
analyse-php / build (push) Has been cancelled
lint-js / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-js / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
Changes the name of our spacing variables due to the prefixing -/_
meaning private in the use of new "use" rather than include.

All now modular too, so all variables/mixins are accessed via their
package.

Also renamed variables file to vars for simpler/cleaner access/writing.

eg. '$-m' is now 'vars.$m'
2024-12-09 13:25:35 +00:00
Dan Brown
617b2edea0
JS: Updated packages, fixed lint issue
Left eslint as old due to eslint-config-airbnb-base not yet being
comptible.
Some SASS deprecations to solve.
2024-12-09 13:07:39 +00:00
Dan Brown
55d074f1a5
Attachment API: Fixed error when name not provided in update
Fixes #5353
2024-12-09 11:32:15 +00:00
Dan Brown
7e6f6af463
Merge pull request #5349 from BookStackApp/lexical_reorg
Some checks failed
test-js / build (push) Has been cancelled
Lexical: Merge of custom nodes & re-organisation of codebase
2024-12-04 20:06:39 +00:00
Dan Brown
d00cf6e1ba
Lexical: Updated tests for node changes 2024-12-04 20:03:05 +00:00
Dan Brown
9fdd100f2d
Lexical: Reorganised custom node code into lexical codebase
Also cleaned up old unused imports.
2024-12-04 18:53:59 +00:00
Dan Brown
57d8449660
Lexical: Merged custom table node code 2024-12-03 20:08:33 +00:00
Dan Brown
ebd4604f21
Lexical: Merged list nodes 2024-12-03 19:03:52 +00:00
Dan Brown
36a4d79120
Lexical: Extracted & merged heading & quote nodes 2024-12-03 17:04:50 +00:00
Dan Brown
f3fa63a5ae
Lexical: Merged custom paragraph node, removed old format/indent refs
Start of work to merge custom nodes into lexical, removing old unused
format/indent core logic while extending common block elements where
possible.
2024-12-03 16:24:49 +00:00
Dan Brown
5164375b18
Merge branch 'rashadkhan359/development' into development
Some checks failed
analyse-php / build (push) Has been cancelled
lint-js / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-js / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
2024-12-03 13:52:38 +00:00
Dan Brown
fec44452cb
Search API: Updated handling of parent detail, added testing
Review of #5280.

- Removed additional non-needed loads which could ignore permissions.
- Updated new formatter method name to be more specific on use.
- Added test case to cover changes.
- Updated API examples to align parent id/info in info to be
  representative.
2024-12-03 13:51:46 +00:00
Dan Brown
18ab38a87b
Merge branch 'fix/markdown-export' into development
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
2024-12-02 11:50:15 +00:00
Dan Brown
0f9957bc03
MD Exports: Added HTML description conversion
Also updated tests to cover checking description use/conversion.
Made during review of #5313
2024-12-02 11:46:56 +00:00
Dan Brown
80f258c3c5
Merge branch 'fix-ldap-display-name' into development
Some checks are pending
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
2024-12-01 18:44:23 +00:00
Dan Brown
90341e0e00
LDAP: Review and testing of mulitple-display-name attr support
Review of #5295
Added test to cover functionality.
Moved splitting from config to service.
2024-12-01 18:42:54 +00:00
Dan Brown
3298374113
Merge branch 'docker-simplify' into development 2024-12-01 16:10:22 +00:00
Dan Brown
227c5e155b
Dev Docker: Fixed missing gd jpeg handling, forced migrations
Migrations run without force could fail startup in certain environment
conditions (when testing production env).
Also updated paths permission handling to update more needed locations.
2024-12-01 16:10:05 +00:00
Dan Brown
fdbbcf2b8a
Merge branch 'portazips' into development
Some checks failed
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
lint-js / build (push) Has been cancelled
test-js / build (push) Has been cancelled
2024-12-01 13:06:43 +00:00
Dan Brown
0a07b0d162
Merge pull request #5259 from BookStackApp/typescript-conversions
Conversion of Services to TypeScript
2024-12-01 13:04:59 +00:00
Dan Brown
94165cc18f
Updated translator & dependency attribution before release v24.10.2
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
2024-11-29 13:46:37 +00:00
Dan Brown
f5ecd51461
Updated translations with latest Crowdin changes (#5331) 2024-11-29 13:40:09 +00:00
Dan Brown
e9f906ce56
Attachments: Fixed full range request handling
We were not responsing with a range request, where the requested range
was for the full extent of content. This changes things to always
provide a range request, even for the full range.

Change made since our existing logic could cause problems in chromium
browsers.

Elseif statement removed as its was likley redundant based upon other
existing checks.
This also changes responses for requested ranges beyond content, but I
think that's technically correct looking at the spec (416 are for when
there are no overlapping request/response ranges at all).

Updated tests to cover.
For #5342
2024-11-29 13:19:55 +00:00
Dan Brown
4630f07282
Code: Set base codemirror line height
Prevents difference in line height between light/dark mode.
For #5146
2024-11-29 12:57:53 +00:00
Dan Brown
978acecdcf
Merge branch 'oidc-content-type-issue' into development
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
2024-11-28 16:58:55 +00:00
Dan Brown
bc1f1d92e5
OIDC: Added extra userinfo content-type normalisation and test
During review of #5337
2024-11-28 16:58:06 +00:00
Dan Brown
415cd6a360
Includes: Workaround for PHP 8.3.14 bug
Changed DOMText creation to be done via document so its document
reference is correct to avoid a bug in PHP 8.3.14.
Ref: https://github.com/php/php-src/issues/16967

Fixes #5341
2024-11-28 16:30:59 +00:00
Dan Brown
68ce340741
Depenencies: Updated PHP packages 2024-11-28 16:25:01 +00:00
Dan Brown
bdca9fc1ce
ZIP Exports: Changed the instance id mechanism
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
Adds an instance id via app settings.
2024-11-27 16:30:19 +00:00
Dan Brown
edb684c72c
ZIP Exports: Updated format doc with advisories regarding html/md
Some checks are pending
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
2024-11-26 17:53:20 +00:00
Wes Biggs
17f7afe12d Updates the OIDC userinfo endpoint request to allow for a Content-Type response header with optional parameters, like application/json; charset=utf-8. This was causing an issue when integrating with [node-oidc-provider](https://github.com/panva/node-oidc-provider). 2024-11-26 11:21:20 -06:00
Dan Brown
0a182a45ba
ZIP Exports: Added detection/handling of images with external storage
Added test to cover.
2024-11-26 15:59:39 +00:00
Dan Brown
95d62e7f57
ZIP Imports/Exports: Fixed some lint and test issues
Some checks failed
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
lint-js / build (push) Has been cancelled
test-js / build (push) Has been cancelled
- Updated test handling to create imports folder when required.
- Updated some tests to delete created import zip files.
2024-11-25 16:30:56 +00:00
Dan Brown
9ecc91929a
ZIP Import & Exports: Addressed issues during testing
- Handled links to within-zip page images found in chapter/book
  descriptions; Added test to cover.
- Fixed session showing unrelated success on failed import.

Tested import file-create undo on failure as part of this testing.
2024-11-25 15:54:15 +00:00
Dan Brown
f79c6aef8d
ZIP Imports: Updated import form to show loading indicator
Some checks are pending
analyse-php / build (push) Waiting to run
lint-js / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-js / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
And disable button after submit.
Added here because the import could take some time, so it's best to show
an indicator to the user to show that something is happening, and help
prevent duplicate submission or re-submit attempts.
2024-11-22 21:36:42 +00:00
Dan Brown
c0dff6d4a6
ZIP Imports: Added book content ordering to import preview 2024-11-22 21:03:04 +00:00
Dan Brown
59cfc087e1
ZIP Imports: Added image type validation/handling
Images were missing their extension after import since it was
(potentially) not part of the import data.
This adds validation via mime sniffing (to match normal image upload
checks) and also uses the same logic to sniff out a correct extension.

Added tests to cover.
Also fixed some existing tests around zip functionality.
2024-11-18 17:42:49 +00:00
Dan Brown
e2f6e50df4
ZIP Exports: Added ID checks and testing to validator 2024-11-18 15:53:21 +00:00
Dan Brown
c2c64e207f
ZIP Imports: Covered import runner with further testing 2024-11-16 19:52:20 +00:00
Dan Brown
8645aeaa4a
ZIP Imports: Started testing core import logic
Fixed image size handling, and lack of attachment reference replacements
during testing.
2024-11-16 16:12:45 +00:00
Dan Brown
7681e32dca
ZIP Imports: Added high level import run tests 2024-11-16 13:57:41 +00:00
Dan Brown
b7476a9e7f
ZIP Import: Finished base import process & error handling
Added file creation reverting and DB rollback on error.
Added error display on failed import.
Extracted likely shown import form/error text to translation files.
2024-11-14 15:59:15 +00:00
Dan Brown
306b8774c2
Updated translations with latest Crowdin changes (#5317)
Some checks failed
analyse-php / build (push) Has been cancelled
lint-js / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-js / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
* New translations common.php (Ukrainian)

* New translations entities.php (Ukrainian)

* New translations errors.php (Ukrainian)

* New translations activities.php (Czech)

* New translations entities.php (Czech)
2024-11-13 11:59:03 +00:00
Dan Brown
c40ab4147e
Dependencies: Updated composer packages 2024-11-13 11:39:04 +00:00
Dan Brown
48c101aa7a
ZIP Imports: Finished off core import logic 2024-11-11 15:06:46 +00:00
Dan Brown
378f0d595f
ZIP Imports: Built out reference parsing/updating logic 2024-11-10 16:03:50 +00:00
czemu
f12946d581 ExportFormatter: Add book description and check for empty book and chapter descriptions in markdown export 2024-11-10 09:39:33 +01:00
Dan Brown
d13e4d2eef
ZIP imports: Started actual import logic 2024-11-09 14:01:24 +00:00
Dan Brown
ac27e18933
Languages: Added Turkmen to locale manager 2024-11-08 13:46:57 +00:00
Dan Brown
e5a6ccc4d4
Translators: Updated before patch release 2024-11-08 13:31:21 +00:00
Dan Brown
e42cdbe8e0
Updated translations with latest Crowdin changes (#5250) 2024-11-08 13:29:21 +00:00
Dan Brown
a6ba8dd68f
Testing: Improved reliability
- Added extra column/value check for page revision test for accuracy.
- Changed search sort test to use more reliable values.
  - Change due to database seeding somtimes generating values that
    proceeded the test value, expected to be first, in sort results.
2024-11-08 11:35:18 +00:00
Dan Brown
7017a1cae5
Update URL Command: Added revisions table support
For #5292
Added test to cover.
2024-11-08 11:22:30 +00:00
Dan Brown
8120278b8c
PHP Deps: Bumped up minor versions 2024-11-08 10:41:25 +00:00
Dan Brown
73babcbfe3
Merge pull request #5312 from BookStackApp/system_cli_update
System CLI update
2024-11-07 17:22:08 +00:00
Dan Brown
45189d9517
System CLI: Updated to 126de5599c state 2024-11-07 17:10:35 +00:00
Dan Brown
7b84558ca1
ZIP Imports: Added parent and permission check pre-import
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
2024-11-05 15:41:58 +00:00
Dan Brown
92cfde495e
ZIP Imports: Added full contents view to import display
Reduced import data will now be stored on the import itself, instead of
storing a set of totals.
2024-11-05 13:17:31 +00:00
Dan Brown
14578c2257
ZIP Imports: Added parent selector for page/chapter imports
Some checks failed
test-php / build (8.2) (push) Has been cancelled
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
2024-11-04 16:21:22 +00:00
Dan Brown
8f6f81948e
ZIP Imports: Fleshed out continue page, Added testing
Some checks are pending
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
2024-11-03 17:28:18 +00:00
Dan Brown
c6109c7087
ZIP Imports: Added listing, show view, delete, activity 2024-11-03 14:13:05 +00:00
Dan Brown
8ea3855e02
ZIP Import: Added upload handling
Some checks are pending
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
Split attachment service storage work out so it can be shared.
2024-11-02 20:48:21 +00:00
Dan Brown
74fce9640e
ZIP Import: Added model+migration, and reader class
Some checks are pending
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
2024-11-02 17:17:34 +00:00
Dan Brown
259aa829d4
ZIP Imports: Added validation message display, added testing
Testing covers main UI access, and main non-successfull import actions.
Started planning stored import model.
Extracted some text to language files.
2024-11-02 14:51:04 +00:00
Dan Brown
c4ec50d437
ZIP Exports: Got zip format validation functionally complete
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
2024-10-30 15:26:23 +00:00
Dan Brown
b50b7b667d
ZIP Exports: Started import validation 2024-10-30 13:13:41 +00:00
Zero
fbeb2e23d4 fix deprecated syntax 2024-10-29 23:07:15 +08:00
Zero
4b60c03caa re-write Dockerfile 2024-10-29 23:06:50 +08:00
Dan Brown
a56a28fbb7
ZIP Exports: Built out initial import view
Some checks are pending
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
Added syles for non-custom, non-image file inputs.
Started planning out back-end handling.
2024-10-29 14:21:32 +00:00
Dan Brown
4051d5b803
ZIP Exports: Added new import permission
Also updated new route/view to new non-book-specific flow.
Also fixed down migration of old export permissions migration.
2024-10-29 12:11:51 +00:00
Matthieu Leboeuf
87242ce6cb Adapt tests with displayName array 2024-10-28 22:27:15 +01:00
Matthieu Leboeuf
72d9ffd8b4
Added support for concatenating multiple LDAP attributes in displayName 2024-10-28 22:14:30 +01:00
Rashad
f606711463 respective book and chapter structure added. 2024-10-27 22:50:20 +05:30
Dan Brown
d1f69feb4a
ZIP Exports: Tested each type and model of export
Some checks failed
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
2024-10-27 14:33:43 +00:00
Dan Brown
e4ca3bf132
Merge pull request #5291 from LordSimal/development
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
fix tests namespace definition
2024-10-27 09:54:11 +00:00
Kevin Pfeifer
7aaf866064 fix tests namespace definition 2024-10-26 13:24:49 +02:00
Dan Brown
484342f26a
ZIP Exports: Added entity cross refs, Started export tests
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
2024-10-23 15:59:58 +01:00
Dan Brown
42ada66fdd
ZIP Exports: Added core logic for books/chapters 2024-10-23 11:30:32 +01:00
Dan Brown
f732ef05d5
ZIP Exports: Reorganised files, added page md parsing 2024-10-23 10:48:26 +01:00
Dan Brown
4fb4fe0931
ZIP Exports: Added working image handling/inclusion
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
2024-10-21 13:59:15 +01:00
Dan Brown
06ffd8ee72
Zip Exports: Added attachment/image link resolving & JSON null handling 2024-10-21 12:13:41 +01:00
Rashad
90a8070518 Eager loading for titles 2024-10-21 03:01:33 +05:30
Rashad
3e656efb00 Added include func for search api 2024-10-21 02:42:49 +05:30
Dan Brown
7c39dd5cba
ZIP Export: Started building link/ref handling
Some checks are pending
analyse-php / build (push) Waiting to run
lint-php / build (push) Waiting to run
test-migrations / build (8.1) (push) Waiting to run
test-migrations / build (8.2) (push) Waiting to run
test-migrations / build (8.3) (push) Waiting to run
test-php / build (8.1) (push) Waiting to run
test-php / build (8.2) (push) Waiting to run
test-php / build (8.3) (push) Waiting to run
2024-10-20 19:56:56 +01:00
Dan Brown
21ccfa97dd
ZIP Export: Expanded page & added base attachment handling
Some checks failed
lint-php / build (push) Has been cancelled
analyse-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
2024-10-19 15:41:07 +01:00
Dan Brown
bf0262d7d1
Testing: Split export tests into multiple files 2024-10-19 13:59:42 +01:00
Dan Brown
42b9700673
ZIP Exports: Finished up format doc, move files, started builder
Some checks failed
test-migrations / build (8.3) (push) Has been cancelled
analyse-php / build (push) Has been cancelled
lint-js / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-js / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
Moved all existing export related app files into their new own dir.
2024-10-15 16:14:11 +01:00
Dan Brown
42bd07d733
ZIP Export: Continued expanding format doc types 2024-10-15 13:57:16 +01:00
Dan Brown
6f1c54d018
Users: Changed name validation to min:1 instead of 2
Some checks failed
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
analyse-php / build (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
Would cause scenarios where users could be created with 1 char, but then
fail to update due to validation differences.
Added test to cover.
For #5263
2024-10-15 11:07:41 +01:00
Dan Brown
1930af91ce
ZIP Export: Started types in format doc 2024-10-13 22:56:22 +01:00
Dan Brown
e088d09e47
ZIP Export: Started defining format 2024-10-13 14:18:23 +01:00
Dan Brown
209fa04752
TS: Converted dom and keyboard nav services
Some checks failed
lint-js / build (push) Has been cancelled
test-js / build (push) Has been cancelled
2024-10-11 21:55:51 +01:00
Dan Brown
f41c02cbd7
TS: Converted app file and animations service
Some checks are pending
lint-js / build (push) Waiting to run
test-js / build (push) Waiting to run
Extracted functions out of app file during changes to clean up.
Altered animation function to use normal css prop names instead of JS
CSS prop names.
2024-10-11 15:19:19 +01:00
Dan Brown
4dc75bad05
Settings: Added test to cover setting category by view
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
2024-10-11 13:33:07 +01:00
Lachlan Tripolone
a3d0f7478f Move settings category layouts into their own view folder 2024-10-11 10:42:48 +11:00
Lachlan Tripolone
b9b5003239 Refactor SettingController to validate categies by existing view files 2024-10-11 10:40:38 +11:00
Dan Brown
2e8d6ce7d9
TS: Coverted util service 2024-10-10 12:03:24 +01:00
814 changed files with 31591 additions and 11781 deletions

View File

@ -56,6 +56,7 @@ APP_PROXIES=null
# Database details
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
# An ipv6 address can be used via the square bracket format ([::1]).
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=database_database

View File

@ -449,3 +449,38 @@ Avishay Rapp (AvishayRapp) :: Hebrew
matthias4217 :: French
Berke BOYLU2 (berkeboylu2) :: Turkish
etwas7B :: German
Mohammed srhiri (m.sghiri20) :: Arabic
YongMin Kim (kym0118) :: Korean
Rivo Zängov (Eraser) :: Estonian
Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian
ИEØ_ΙΙØZ (NEO_IIOZ) :: Chinese Traditional
madnjpn (madnjpn.) :: Georgian
Ásgeir Shiny Ásgeirsson (AsgeirShiny) :: Icelandic
Mohammad Aftab Uddin (chirohorit) :: Bengali
Yannis Karlaftis (meliseus) :: Greek
felixxx :: German Informal
randi (randi65535) :: Korean
test65428 :: Greek
zeronell :: Chinese Simplified
julien Vinber (julienVinber) :: French
Hyunwoo Park (oksure) :: Korean
aram.rafeq.7 (aramrafeq2) :: Kurdish
Raphael Moreno (RaphaelMoreno) :: Portuguese, Brazilian
yn (user99) :: Arabic
Pavel Zlatarov (pzlatarov) :: Bulgarian
ingelres :: French
mabdullah :: Arabic
Skrabák Csaba (kekcsi) :: Hungarian
Evert Meulie (Evert) :: Norwegian Bokmal
Jasper Backer (jasperb) :: Dutch
Alexandar Cavdarovski (ace.200112) :: Swedish
구닥다리TV (yjj8353) :: Korean
Onur Oskay (o.oskay) :: Turkish
Sébastien Merveille (SebastienMerv) :: French
Maxim Kouznetsov (masya.work) :: Hebrew
neodvisnost :: Slovenian
Soubi Agatsuma (bisouya) :: Hebrew
Ilya Shaulov (ishaulov) :: Russian
Konstantin Bobkov (b.konstantv) :: Russian
Ruben Sutter (rubensutter) :: German
jellium :: French

View File

@ -11,9 +11,9 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2

View File

@ -11,14 +11,14 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.1
php-version: 8.3
tools: phpcs
- name: Run formatting check

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

@ -8,27 +8,15 @@ use Illuminate\Database\Eloquent\Model;
class ExternalBaseUserProvider implements UserProvider
{
/**
* The user model.
*
* @var string
*/
protected $model;
/**
* LdapUserProvider constructor.
*/
public function __construct(string $model)
{
$this->model = $model;
public function __construct(
protected string $model
) {
}
/**
* Create a new instance of the model.
*
* @return Model
*/
public function createModel()
public function createModel(): Model
{
$class = '\\' . ltrim($this->model, '\\');
@ -37,12 +25,8 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Retrieve a user by their unique identifier.
*
* @param mixed $identifier
*
* @return Authenticatable|null
*/
public function retrieveById($identifier)
public function retrieveById(mixed $identifier): ?Authenticatable
{
return $this->createModel()->newQuery()->find($identifier);
}
@ -50,12 +34,9 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Retrieve a user by their unique identifier and "remember me" token.
*
* @param mixed $identifier
* @param string $token
*
* @return Authenticatable|null
*/
public function retrieveByToken($identifier, $token)
public function retrieveByToken(mixed $identifier, $token): null
{
return null;
}
@ -75,12 +56,8 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Retrieve a user by the given credentials.
*
* @param array $credentials
*
* @return Authenticatable|null
*/
public function retrieveByCredentials(array $credentials)
public function retrieveByCredentials(array $credentials): ?Authenticatable
{
// Search current user base by looking up a uid
$model = $this->createModel();
@ -92,15 +69,15 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Validate a user against the given credentials.
*
* @param Authenticatable $user
* @param array $credentials
*
* @return bool
*/
public function validateCredentials(Authenticatable $user, array $credentials)
public function validateCredentials(Authenticatable $user, array $credentials): bool
{
// Should be done in the guard.
return false;
}
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
{
// No action to perform, any passwords are external in the auth system
}
}

View File

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

View File

@ -71,6 +71,26 @@ class LdapService
return $users[0];
}
/**
* Build the user display name from the (potentially multiple) attributes defined by the configuration.
*/
protected function getUserDisplayName(array $userDetails, array $displayNameAttrs, string $defaultValue): string
{
$displayNameParts = [];
foreach ($displayNameAttrs as $dnAttr) {
$dnComponent = $this->getUserResponseProperty($userDetails, $dnAttr, null);
if ($dnComponent) {
$displayNameParts[] = $dnComponent;
}
}
if (empty($displayNameParts)) {
return $defaultValue;
}
return implode(' ', $displayNameParts);
}
/**
* Get the details of a user from LDAP using the given username.
* User found via configurable user filter.
@ -81,21 +101,25 @@ class LdapService
{
$idAttr = $this->config['id_attribute'];
$emailAttr = $this->config['email_attribute'];
$displayNameAttr = $this->config['display_name_attribute'];
$displayNameAttrs = explode('|', $this->config['display_name_attribute']);
$thumbnailAttr = $this->config['thumbnail_attribute'];
$user = $this->getUserWithAttributes($userName, array_filter([
'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr,
'cn', 'dn', $idAttr, $emailAttr, ...$displayNameAttrs, $thumbnailAttr,
]));
if (is_null($user)) {
return null;
}
$userCn = $this->getUserResponseProperty($user, 'cn', null);
$nameDefault = $this->getUserResponseProperty($user, 'cn', null);
if (is_null($nameDefault)) {
$nameDefault = ldap_explode_dn($user['dn'], 1)[0] ?? $user['dn'];
}
$formatted = [
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'name' => $this->getUserDisplayName($user, $displayNameAttrs, $nameDefault),
'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,

View File

@ -5,6 +5,7 @@ namespace BookStack\Access;
use BookStack\Access\Mfa\MfaSession;
use BookStack\Activity\ActivityType;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Exceptions\LoginAttemptInvalidUserException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
@ -29,10 +30,14 @@ class LoginService
* a reason to (MFA or Unconfirmed Email).
* Returns a boolean to indicate the current login result.
*
* @throws StoppedAuthenticationException
* @throws StoppedAuthenticationException|LoginAttemptInvalidUserException
*/
public function login(User $user, string $method, bool $remember = false): void
{
if ($user->isGuest()) {
throw new LoginAttemptInvalidUserException('Login not allowed for guest user');
}
if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
$this->setLastLoginAttemptedForUser($user, $method, $remember);
@ -58,7 +63,7 @@ class LoginService
*
* @throws Exception
*/
public function reattemptLoginFor(User $user)
public function reattemptLoginFor(User $user): void
{
if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
throw new Exception('Login reattempt user does align with current session state');
@ -152,16 +157,40 @@ class LoginService
*/
public function attempt(array $credentials, string $method, bool $remember = false): bool
{
if ($this->areCredentialsForGuest($credentials)) {
return false;
}
$result = auth()->attempt($credentials, $remember);
if ($result) {
$user = auth()->user();
auth()->logout();
$this->login($user, $method, $remember);
try {
$this->login($user, $method, $remember);
} catch (LoginAttemptInvalidUserException $e) {
// Catch and return false for non-login accounts
// so it looks like a normal invalid login.
return false;
}
}
return $result;
}
/**
* Check if the given credentials are likely for the system guest account.
*/
protected function areCredentialsForGuest(array $credentials): bool
{
if (isset($credentials['email'])) {
return User::query()->where('email', '=', $credentials['email'])
->where('system_name', '=', 'public')
->exists();
}
return false;
}
/**
* Logs the current user out of the application.
* Returns an app post-redirect path.

View File

@ -11,7 +11,9 @@ class OidcUserinfoResponse implements ProvidesClaims
public function __construct(ResponseInterface $response, string $issuer, array $keys)
{
$contentType = $response->getHeader('Content-Type')[0];
$contentTypeHeaderValue = $response->getHeader('Content-Type')[0] ?? '';
$contentType = strtolower(trim(explode(';', $contentTypeHeaderValue, 2)[0]));
if ($contentType === 'application/json') {
$this->claims = json_decode($response->getBody()->getContents(), true);
}

View File

@ -92,7 +92,7 @@ class SocialDriverManager
string $driverName,
array $config,
string $socialiteHandler,
callable $configureForRedirect = null
?callable $configureForRedirect = null
) {
$this->validDrivers[] = $driverName;
config()->set('services.' . $driverName, $config);

View File

@ -67,6 +67,14 @@ class ActivityType
const WEBHOOK_UPDATE = 'webhook_update';
const WEBHOOK_DELETE = 'webhook_delete';
const IMPORT_CREATE = 'import_create';
const IMPORT_RUN = 'import_run';
const IMPORT_DELETE = 'import_delete';
const SORT_RULE_CREATE = 'sort_rule_create';
const SORT_RULE_UPDATE = 'sort_rule_update';
const SORT_RULE_DELETE = 'sort_rule_delete';
/**
* Get all the possible values.
*/

View File

@ -5,6 +5,7 @@ namespace BookStack\Activity\Controllers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Http\Controller;
use BookStack\Sorting\SortUrl;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
@ -65,6 +66,7 @@ class AuditLogController extends Controller
'filters' => $filters,
'listOptions' => $listOptions,
'activityTypes' => $types,
'filterSortUrl' => new SortUrl('settings/audit', array_filter($request->except('page')))
]);
}
}

View File

@ -26,7 +26,6 @@ class Comment extends Model implements Loggable
use HasCreatorAndUpdater;
protected $fillable = ['parent_id'];
protected $appends = ['created', 'updated'];
/**
* Get the entity that this comment belongs to.
@ -54,22 +53,6 @@ class Comment extends Model implements Loggable
return $this->updated_at->timestamp > $this->created_at->timestamp;
}
/**
* Get created date as a relative diff.
*/
public function getCreatedAttribute(): string
{
return $this->created_at->diffForHumans();
}
/**
* Get updated date as a relative diff.
*/
public function getUpdatedAttribute(): string
{
return $this->updated_at->diffForHumans();
}
public function logDescriptor(): string
{
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";

View File

@ -7,6 +7,7 @@ use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
use BookStack\Entities\Models\Entity;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Log;
abstract class BaseNotificationHandler implements NotificationHandler
{
@ -36,7 +37,11 @@ abstract class BaseNotificationHandler implements NotificationHandler
}
// Send the notification
$user->notify(new $notification($detail, $initiator));
try {
$user->notify(new $notification($detail, $initiator));
} catch (\Exception $exception) {
Log::error("Failed to send email notification to user [id:{$user->id}] with error: {$exception->getMessage()}");
}
}
}
}

View File

@ -2,7 +2,9 @@
namespace BookStack\Api;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
class ApiEntityListFormatter
{
@ -20,8 +22,16 @@ class ApiEntityListFormatter
* @var array<string|int, string|callable>
*/
protected array $fields = [
'id', 'name', 'slug', 'book_id', 'chapter_id', 'draft',
'template', 'priority', 'created_at', 'updated_at',
'id',
'name',
'slug',
'book_id',
'chapter_id',
'draft',
'template',
'priority',
'created_at',
'updated_at',
];
public function __construct(array $list)
@ -62,6 +72,28 @@ class ApiEntityListFormatter
return $this;
}
/**
* Include parent book/chapter info in the formatted data.
*/
public function withParents(): self
{
$this->withField('book', function (Entity $entity) {
if ($entity instanceof BookChild && $entity->book) {
return $entity->book->only(['id', 'name', 'slug']);
}
return null;
});
$this->withField('chapter', function (Entity $entity) {
if ($entity instanceof Page && $entity->chapter) {
return $entity->chapter->only(['id', 'name', 'slug']);
}
return null;
});
return $this;
}
/**
* Format the data and return an array of formatted content.
* @return array[]

View File

@ -42,4 +42,12 @@ class EventServiceProvider extends ServiceProvider
{
return false;
}
/**
* Overrides the registration of Laravel's default email verification system
*/
protected function configureEmailVerification(): void
{
//
}
}

View File

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

View File

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

View File

@ -1,37 +0,0 @@
<?php
/**
* Broadcasting configuration options.
*
* Changes to these config files are not supported by BookStack and may break upon updates.
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
return [
// Default Broadcaster
// This option controls the default broadcaster that will be used by the
// framework when an event needs to be broadcast. This can be set to
// any of the connections defined in the "connections" array below.
'default' => 'null',
// Broadcast Connections
// Here you may define all of the broadcast connections that will be used
// to broadcast events to other systems or over websockets. Samples of
// each available type of connection are provided inside this array.
'connections' => [
// Default options removed since we don't use broadcasting.
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

View File

@ -35,10 +35,6 @@ return [
// Available caches stores
'stores' => [
'apc' => [
'driver' => 'apc',
],
'array' => [
'driver' => 'array',
'serialize' => false,
@ -49,6 +45,7 @@ return [
'table' => 'cache',
'connection' => null,
'lock_connection' => null,
'lock_table' => null,
],
'file' => [

View File

@ -40,12 +40,16 @@ if (env('REDIS_SERVERS', false)) {
// MYSQL
// Split out port from host if set
$mysql_host = env('DB_HOST', 'localhost');
$mysql_host_exploded = explode(':', $mysql_host);
$mysql_port = env('DB_PORT', 3306);
if (count($mysql_host_exploded) > 1) {
$mysql_host = $mysql_host_exploded[0];
$mysql_port = intval($mysql_host_exploded[1]);
$mysqlHost = env('DB_HOST', 'localhost');
$mysqlHostExploded = explode(':', $mysqlHost);
$mysqlPort = env('DB_PORT', 3306);
$mysqlHostIpv6 = str_starts_with($mysqlHost, '[');
if ($mysqlHostIpv6 && str_contains($mysqlHost, ']:')) {
$mysqlHost = implode(':', array_slice($mysqlHostExploded, 0, -1));
$mysqlPort = intval(end($mysqlHostExploded));
} else if (!$mysqlHostIpv6 && count($mysqlHostExploded) > 1) {
$mysqlHost = $mysqlHostExploded[0];
$mysqlPort = intval($mysqlHostExploded[1]);
}
return [
@ -61,12 +65,12 @@ return [
'mysql' => [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'host' => $mysql_host,
'host' => $mysqlHost,
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'port' => $mysql_port,
'port' => $mysqlPort,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
// Prefixes are only semi-supported and may be unstable
@ -88,7 +92,7 @@ return [
'database' => 'bookstack-test',
'username' => env('MYSQL_USER', 'bookstack-test'),
'password' => env('MYSQL_PASSWORD', 'bookstack-test'),
'port' => $mysql_port,
'port' => $mysqlPort,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',

View File

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

View File

@ -33,12 +33,14 @@ return [
'driver' => 'local',
'root' => public_path(),
'visibility' => 'public',
'serve' => false,
'throw' => true,
],
'local_secure_attachments' => [
'driver' => 'local',
'root' => storage_path('uploads/files/'),
'serve' => false,
'throw' => true,
],
@ -46,6 +48,7 @@ return [
'driver' => 'local',
'root' => storage_path('uploads/images/'),
'visibility' => 'public',
'serve' => false,
'throw' => true,
],

View File

@ -38,7 +38,7 @@ return [
'password' => env('MAIL_PASSWORD'),
'verify_peer' => env('MAIL_VERIFY_SSL', true),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN'),
'local_domain' => null,
'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'),
],
@ -64,12 +64,4 @@ return [
],
],
],
// Email markdown configuration
'markdown' => [
'theme' => 'default',
'paths' => [
resource_path('views/vendor/mail'),
],
],
];

View File

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

View File

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

View File

@ -49,6 +49,7 @@ class UpdateUrlCommand extends Command
'chapters' => ['description_html'],
'books' => ['description_html'],
'bookshelves' => ['description_html'],
'page_revisions' => ['html', 'text', 'markdown'],
'images' => ['url'],
'settings' => ['value'],
'comments' => ['html', 'text'],
@ -77,6 +78,12 @@ class UpdateUrlCommand extends Command
$this->info('URL update procedure complete.');
$this->info('============================================================================');
$this->info('Be sure to run "php artisan cache:clear" to clear any old URLs in the cache.');
if (!str_starts_with($newUrl, url('/'))) {
$this->warn('You still need to update your APP_URL env value. This is currently set to:');
$this->warn(url('/'));
}
$this->info('============================================================================');
return 0;

View File

@ -30,6 +30,7 @@ class BookApiController extends ApiController
{
$books = $this->queries
->visibleForList()
->with(['cover:id,name,url'])
->addSelect(['created_by', 'updated_by']);
return $this->apiListingResponse($books, [

View File

@ -70,7 +70,7 @@ class BookController extends Controller
/**
* Show the form for creating a new book.
*/
public function create(string $shelfSlug = null)
public function create(?string $shelfSlug = null)
{
$this->checkPermission('book-create-all');
@ -93,7 +93,7 @@ class BookController extends Controller
* @throws ImageUploadException
* @throws ValidationException
*/
public function store(Request $request, string $shelfSlug = null)
public function store(Request $request, ?string $shelfSlug = null)
{
$this->checkPermission('book-create-all');
$validated = $this->validate($request, [

View File

@ -26,6 +26,7 @@ class BookshelfApiController extends ApiController
{
$shelves = $this->queries
->visibleForList()
->with(['cover:id,name,url'])
->addSelect(['created_by', 'updated_by']);
return $this->apiListingResponse($shelves, [

View File

@ -41,7 +41,7 @@ class PageController extends Controller
*
* @throws Throwable
*/
public function create(string $bookSlug, string $chapterSlug = null)
public function create(string $bookSlug, ?string $chapterSlug = null)
{
if ($chapterSlug) {
$parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
@ -69,7 +69,7 @@ class PageController extends Controller
*
* @throws ValidationException
*/
public function createAsGuest(Request $request, string $bookSlug, string $chapterSlug = null)
public function createAsGuest(Request $request, string $bookSlug, ?string $chapterSlug = null)
{
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],

View File

@ -43,7 +43,6 @@ class PageRevisionController extends Controller
->selectRaw("IF(markdown = '', false, true) as is_markdown")
->with(['page.book', 'createdBy'])
->reorder('id', $listOptions->getOrder())
->reorder('created_at', $listOptions->getOrder())
->paginate(50);
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName' => $page->getShortName()]));
@ -52,6 +51,7 @@ class PageRevisionController extends Controller
'revisions' => $revisions,
'page' => $page,
'listOptions' => $listOptions,
'oldestRevisionId' => $page->revisions()->min('id'),
]);
}

View File

@ -2,6 +2,7 @@
namespace BookStack\Entities\Models;
use BookStack\Sorting\SortRule;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -16,12 +17,14 @@ use Illuminate\Support\Collection;
* @property string $description
* @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
*/
class Book extends Entity implements HasCoverImage
{
@ -82,6 +85,14 @@ class Book extends Entity implements HasCoverImage
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.
*/

View File

@ -60,6 +60,7 @@ class Chapter extends BookChild
/**
* Get the visible pages in this chapter.
* @returns Collection<Page>
*/
public function getVisiblePages(): Collection
{

View File

@ -18,7 +18,7 @@ class QueryPopular
) {
}
public function run(int $count, int $page, array $filterModels = null): Collection
public function run(int $count, int $page, array $filterModels): Collection
{
$query = $this->permissions
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
@ -26,7 +26,7 @@ class QueryPopular
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');
if ($filterModels) {
if (!empty($filterModels)) {
$query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels));
}

View File

@ -4,6 +4,7 @@ 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\Entity;
use BookStack\Entities\Models\HasCoverImage;
@ -12,6 +13,7 @@ use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater;
use BookStack\Sorting\BookSorter;
use BookStack\Uploads\ImageRepo;
use BookStack\Util\HtmlDescriptionFilter;
use Illuminate\Http\UploadedFile;
@ -24,6 +26,7 @@ class BaseRepo
protected ReferenceUpdater $referenceUpdater,
protected ReferenceStore $referenceStore,
protected PageQueries $pageQueries,
protected BookSorter $bookSorter,
) {
}
@ -134,6 +137,18 @@ class BaseRepo
$entity->save();
}
/**
* Sort the parent of the given entity, if any auto sort actions are set for it.
* Typical ran during create/update/insert events.
*/
public function sortParent(Entity $entity): void
{
if ($entity instanceof BookChild) {
$book = $entity->book;
$this->bookSorter->runBookAutoSort($book);
}
}
protected function updateDescription(Entity $entity, array $input): void
{
if (!in_array(HasHtmlDescription::class, class_uses($entity))) {

View File

@ -8,6 +8,7 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Activity;
use BookStack\Sorting\SortRule;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Http\UploadedFile;
@ -33,6 +34,12 @@ class BookRepo
$this->baseRepo->updateDefaultTemplate($book, 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();
}
return $book;
}

View File

@ -34,6 +34,8 @@ class ChapterRepo
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
$this->baseRepo->sortParent($chapter);
return $chapter;
}
@ -50,6 +52,8 @@ class ChapterRepo
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
$this->baseRepo->sortParent($chapter);
return $chapter;
}
@ -88,6 +92,8 @@ class ChapterRepo
$chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
$this->baseRepo->sortParent($chapter);
return $parent;
}
}

View File

@ -83,10 +83,22 @@ class PageRepo
$draft->refresh();
Activity::add(ActivityType::PAGE_CREATE, $draft);
$this->baseRepo->sortParent($draft);
return $draft;
}
/**
* Directly update the content for the given page from the provided input.
* Used for direct content access in a way that performs required changes
* (Search index & reference regen) without performing an official update.
*/
public function setContentFromInput(Page $page, array $input): void
{
$this->updateTemplateStatusAndContentFromInput($page, $input);
$this->baseRepo->update($page, []);
}
/**
* Update a page in the system.
*/
@ -117,11 +129,12 @@ class PageRepo
}
Activity::add(ActivityType::PAGE_UPDATE, $page);
$this->baseRepo->sortParent($page);
return $page;
}
protected function updateTemplateStatusAndContentFromInput(Page $page, array $input)
protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void
{
if (isset($input['template']) && userCan('templates-manage')) {
$page->template = ($input['template'] === 'true');
@ -232,6 +245,8 @@ class PageRepo
Activity::add(ActivityType::PAGE_RESTORE, $page);
Activity::add(ActivityType::REVISION_RESTORE, $revision);
$this->baseRepo->sortParent($page);
return $page;
}
@ -261,6 +276,8 @@ class PageRepo
Activity::add(ActivityType::PAGE_MOVE, $page);
$this->baseRepo->sortParent($page);
return $parent;
}

View File

@ -46,7 +46,7 @@ class RevisionRepo
/**
* Store a new revision in the system for the given page.
*/
public function storeNewForPage(Page $page, string $summary = null): PageRevision
public function storeNewForPage(Page $page, ?string $summary = null): PageRevision
{
$revision = new PageRevision();

View File

@ -8,6 +8,8 @@ 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
@ -103,211 +105,4 @@ class BookContents
return $query->where('book_id', '=', $this->book->id)->get();
}
/**
* Sort the books content using the given sort map.
* Returns a list of books that were involved in the operation.
*
* @returns Book[]
*/
public function sortUsingMap(BookSortMap $sortMap): array
{
// Load models into map
$modelMap = $this->loadModelsFromSortMap($sortMap);
// Sort our changes from our map to be chapters first
// Since they need to be process to ensure book alignment for child page changes.
$sortMapItems = $sortMap->all();
usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
$aScore = $itemA->type === 'page' ? 2 : 1;
$bScore = $itemB->type === 'page' ? 2 : 1;
return $aScore - $bScore;
});
// Perform the sort
foreach ($sortMapItems as $item) {
$this->applySortUpdates($item, $modelMap);
}
/** @var Book[] $booksInvolved */
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
return str_starts_with($key, 'book:');
}, ARRAY_FILTER_USE_KEY));
// Update permissions of books involved
foreach ($booksInvolved as $book) {
$book->rebuildPermissions();
}
return $booksInvolved;
}
/**
* Using the given sort map item, detect changes for the related model
* and update it if required. Changes where permissions are lacking will
* be skipped and not throw an error.
*
* @param array<string, Entity> $modelMap
*/
protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
{
/** @var BookChild $model */
$model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
if (!$model) {
return;
}
$priorityChanged = $model->priority !== $sortMapItem->sort;
$bookChanged = $model->book_id !== $sortMapItem->parentBookId;
$chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
// Stop if there's no change
if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
return;
}
$currentParentKey = 'book:' . $model->book_id;
if ($model instanceof Page && $model->chapter_id) {
$currentParentKey = 'chapter:' . $model->chapter_id;
}
$currentParent = $modelMap[$currentParentKey] ?? null;
/** @var Book $newBook */
$newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
/** @var ?Chapter $newChapter */
$newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
return;
}
// Action the required changes
if ($bookChanged) {
$model->changeBook($newBook->id);
}
if ($model instanceof Page && $chapterChanged) {
$model->chapter_id = $newChapter->id ?? 0;
}
if ($priorityChanged) {
$model->priority = $sortMapItem->sort;
}
if ($chapterChanged || $priorityChanged) {
$model->save();
}
}
/**
* Check if the current user has permissions to apply the given sorting change.
* Is quite complex since items can gain a different parent change. Acts as a:
* - Update of old parent element (Change of content/order).
* - Update of sorted/moved element.
* - Deletion of element (Relative to parent upon move).
* - Creation of element within parent (Upon move to new parent).
*/
protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
{
// Stop if we can't see the current parent or new book.
if (!$currentParent || !$newBook) {
return false;
}
$hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
if ($model instanceof Chapter) {
$hasPermission = userCan('book-update', $currentParent)
&& userCan('book-update', $newBook)
&& userCan('chapter-update', $model)
&& (!$hasNewParent || userCan('chapter-create', $newBook))
&& (!$hasNewParent || userCan('chapter-delete', $model));
if (!$hasPermission) {
return false;
}
}
if ($model instanceof Page) {
$parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasCurrentParentPermission = userCan($parentPermission, $currentParent);
// This needs to check if there was an intended chapter location in the original sort map
// rather than inferring from the $newChapter since that variable may be null
// due to other reasons (Visibility).
$newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
if (!$newParent) {
return false;
}
$hasPageEditPermission = userCan('page-update', $model);
$newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasNewParentPermission = userCan($newParentPermission, $newParent);
$hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
$hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
$hasPermission = $hasCurrentParentPermission
&& $newParentInRightLocation
&& $hasNewParentPermission
&& $hasPageEditPermission
&& $hasDeletePermissionIfMoving
&& $hasCreatePermissionIfMoving;
if (!$hasPermission) {
return false;
}
}
return true;
}
/**
* Load models from the database into the given sort map.
*
* @return array<string, Entity>
*/
protected function loadModelsFromSortMap(BookSortMap $sortMap): array
{
$modelMap = [];
$ids = [
'chapter' => [],
'page' => [],
'book' => [],
];
foreach ($sortMap->all() as $sortMapItem) {
$ids[$sortMapItem->type][] = $sortMapItem->id;
$ids['book'][] = $sortMapItem->parentBookId;
if ($sortMapItem->parentChapterId) {
$ids['chapter'][] = $sortMapItem->parentChapterId;
}
}
$pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
/** @var Page $page */
foreach ($pages as $page) {
$modelMap['page:' . $page->id] = $page;
$ids['book'][] = $page->book_id;
if ($page->chapter_id) {
$ids['chapter'][] = $page->chapter_id;
}
}
$chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
/** @var Chapter $chapter */
foreach ($chapters as $chapter) {
$modelMap['chapter:' . $chapter->id] = $chapter;
$ids['book'][] = $chapter->book_id;
}
$books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
/** @var Book $book */
foreach ($books as $book) {
$modelMap['book:' . $book->id] = $book;
}
return $modelMap;
}
}

View File

@ -18,17 +18,12 @@ use Illuminate\Http\UploadedFile;
class Cloner
{
protected PageRepo $pageRepo;
protected ChapterRepo $chapterRepo;
protected BookRepo $bookRepo;
protected ImageService $imageService;
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
{
$this->pageRepo = $pageRepo;
$this->chapterRepo = $chapterRepo;
$this->bookRepo = $bookRepo;
$this->imageService = $imageService;
public function __construct(
protected PageRepo $pageRepo,
protected ChapterRepo $chapterRepo,
protected BookRepo $bookRepo,
protected ImageService $imageService,
) {
}
/**

View File

@ -104,10 +104,10 @@ class PageIncludeParser
if ($currentOffset < $tagStartOffset) {
$previousText = substr($text, $currentOffset, $tagStartOffset - $currentOffset);
$textNode->parentNode->insertBefore(new DOMText($previousText), $textNode);
$textNode->parentNode->insertBefore($this->doc->createTextNode($previousText), $textNode);
}
$node = $textNode->parentNode->insertBefore(new DOMText($tagOuterContent), $textNode);
$node = $textNode->parentNode->insertBefore($this->doc->createTextNode($tagOuterContent), $textNode);
$includeTags[] = new PageIncludeTag($tagInnerContent, $node);
$currentOffset = $tagStartOffset + strlen($tagOuterContent);
}

View File

@ -0,0 +1,7 @@
<?php
namespace BookStack\Exceptions;
class LoginAttemptInvalidUserException extends LoginAttemptException
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace BookStack\Exceptions;
class ZipExportException extends \Exception
{
}

View File

@ -0,0 +1,13 @@
<?php
namespace BookStack\Exceptions;
class ZipImportException extends \Exception
{
public function __construct(
public array $errors
) {
$message = "Import failed with errors:" . implode("\n", $this->errors);
parent::__construct($message);
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace BookStack\Exceptions;
class ZipValidationException extends \Exception
{
public function __construct(
public array $errors
) {
parent::__construct();
}
}

View File

@ -1,9 +1,9 @@
<?php
namespace BookStack\Entities\Controllers;
namespace BookStack\Exports\Controllers;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Exports\ExportFormatter;
use BookStack\Http\ApiController;
use Throwable;

View File

@ -1,9 +1,11 @@
<?php
namespace BookStack\Entities\Controllers;
namespace BookStack\Exports\Controllers;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\Controller;
use Throwable;
@ -14,6 +16,7 @@ class BookExportController extends Controller
protected ExportFormatter $exportFormatter,
) {
$this->middleware('can:content-export');
$this->middleware('throttle:exports');
}
/**
@ -63,4 +66,16 @@ class BookExportController extends Controller
return $this->download()->directly($textContent, $bookSlug . '.md');
}
/**
* Export a book to a contained ZIP export file.
* @throws NotFoundException
*/
public function zip(string $bookSlug, ZipExportBuilder $builder)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$zip = $builder->buildForBook($book);
return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', true);
}
}

View File

@ -1,9 +1,9 @@
<?php
namespace BookStack\Entities\Controllers;
namespace BookStack\Exports\Controllers;
use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Exports\ExportFormatter;
use BookStack\Http\ApiController;
use Throwable;

View File

@ -1,10 +1,11 @@
<?php
namespace BookStack\Entities\Controllers;
namespace BookStack\Exports\Controllers;
use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\Controller;
use Throwable;
@ -15,6 +16,7 @@ class ChapterExportController extends Controller
protected ExportFormatter $exportFormatter,
) {
$this->middleware('can:content-export');
$this->middleware('throttle:exports');
}
/**
@ -70,4 +72,16 @@ class ChapterExportController extends Controller
return $this->download()->directly($chapterText, $chapterSlug . '.md');
}
/**
* Export a book to a contained ZIP export file.
* @throws NotFoundException
*/
public function zip(string $bookSlug, string $chapterSlug, ZipExportBuilder $builder)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$zip = $builder->buildForChapter($chapter);
return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', true);
}
}

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace BookStack\Exports\Controllers;
use BookStack\Exceptions\ZipImportException;
use BookStack\Exceptions\ZipValidationException;
use BookStack\Exports\ImportRepo;
use BookStack\Http\Controller;
use BookStack\Uploads\AttachmentService;
use Illuminate\Http\Request;
class ImportController extends Controller
{
public function __construct(
protected ImportRepo $imports,
) {
$this->middleware('can:content-import');
}
/**
* Show the view to start a new import, and also list out the existing
* in progress imports that are visible to the user.
*/
public function start()
{
$imports = $this->imports->getVisibleImports();
$this->setPageTitle(trans('entities.import'));
return view('exports.import', [
'imports' => $imports,
'zipErrors' => session()->pull('validation_errors') ?? [],
]);
}
/**
* Upload, validate and store an import file.
*/
public function upload(Request $request)
{
$this->validate($request, [
'file' => ['required', ...AttachmentService::getFileValidationRules()]
]);
$file = $request->file('file');
try {
$import = $this->imports->storeFromUpload($file);
} catch (ZipValidationException $exception) {
return redirect('/import')->with('validation_errors', $exception->errors);
}
return redirect($import->getUrl());
}
/**
* Show a pending import, with a form to allow progressing
* with the import process.
*/
public function show(int $id)
{
$import = $this->imports->findVisible($id);
$this->setPageTitle(trans('entities.import_continue'));
return view('exports.import-show', [
'import' => $import,
'data' => $import->decodeMetadata(),
]);
}
/**
* Run the import process against an uploaded import ZIP.
*/
public function run(int $id, Request $request)
{
$import = $this->imports->findVisible($id);
$parent = null;
if ($import->type === 'page' || $import->type === 'chapter') {
session()->setPreviousUrl($import->getUrl());
$data = $this->validate($request, [
'parent' => ['required', 'string'],
]);
$parent = $data['parent'];
}
try {
$entity = $this->imports->runImport($import, $parent);
} catch (ZipImportException $exception) {
session()->flush();
$this->showErrorNotification(trans('errors.import_zip_failed_notification'));
return redirect($import->getUrl())->with('import_errors', $exception->errors);
}
return redirect($entity->getUrl());
}
/**
* Delete an active pending import from the filesystem and database.
*/
public function delete(int $id)
{
$import = $this->imports->findVisible($id);
$this->imports->deleteImport($import);
return redirect('/import');
}
}

View File

@ -1,9 +1,9 @@
<?php
namespace BookStack\Entities\Controllers;
namespace BookStack\Exports\Controllers;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Exports\ExportFormatter;
use BookStack\Http\ApiController;
use Throwable;

View File

@ -1,11 +1,12 @@
<?php
namespace BookStack\Entities\Controllers;
namespace BookStack\Exports\Controllers;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Entities\Tools\PageContent;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\Controller;
use Throwable;
@ -16,6 +17,7 @@ class PageExportController extends Controller
protected ExportFormatter $exportFormatter,
) {
$this->middleware('can:content-export');
$this->middleware('throttle:exports');
}
/**
@ -74,4 +76,16 @@ class PageExportController extends Controller
return $this->download()->directly($pageText, $pageSlug . '.md');
}
/**
* Export a page to a contained ZIP export file.
* @throws NotFoundException
*/
public function zip(string $bookSlug, string $pageSlug, ZipExportBuilder $builder)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$zip = $builder->buildForPage($page);
return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', true);
}
}

View File

@ -1,11 +1,13 @@
<?php
namespace BookStack\Entities\Tools;
namespace BookStack\Exports;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Entities\Tools\PageContent;
use BookStack\Uploads\ImageService;
use BookStack\Util\CspService;
use BookStack\Util\HtmlDocument;
@ -315,7 +317,12 @@ class ExportFormatter
public function chapterToMarkdown(Chapter $chapter): string
{
$text = '# ' . $chapter->name . "\n\n";
$text .= $chapter->description . "\n\n";
$description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert();
if ($description) {
$text .= $description . "\n\n";
}
foreach ($chapter->pages as $page) {
$text .= $this->pageToMarkdown($page) . "\n\n";
}
@ -330,6 +337,12 @@ class ExportFormatter
{
$bookTree = (new BookContents($book))->getTree(false, true);
$text = '# ' . $book->name . "\n\n";
$description = (new HtmlToMarkdown($book->descriptionHtml()))->convert();
if ($description) {
$text .= $description . "\n\n";
}
foreach ($bookTree as $bookChild) {
if ($bookChild instanceof Chapter) {
$text .= $this->chapterToMarkdown($bookChild) . "\n\n";

66
app/Exports/Import.php Normal file
View File

@ -0,0 +1,66 @@
<?php
namespace BookStack\Exports;
use BookStack\Activity\Models\Loggable;
use BookStack\Exports\ZipExports\Models\ZipExportBook;
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Users\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property string $path
* @property string $name
* @property int $size - ZIP size in bytes
* @property string $type
* @property string $metadata
* @property int $created_by
* @property Carbon $created_at
* @property Carbon $updated_at
* @property User $createdBy
*/
class Import extends Model implements Loggable
{
use HasFactory;
public function getSizeString(): string
{
$mb = round($this->size / 1000000, 2);
return "{$mb} MB";
}
/**
* Get the URL to view/continue this import.
*/
public function getUrl(string $path = ''): string
{
$path = ltrim($path, '/');
return url("/import/{$this->id}" . ($path ? '/' . $path : ''));
}
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}";
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function decodeMetadata(): ZipExportBook|ZipExportChapter|ZipExportPage|null
{
$metadataArray = json_decode($this->metadata, true);
return match ($this->type) {
'book' => ZipExportBook::fromArray($metadataArray),
'chapter' => ZipExportChapter::fromArray($metadataArray),
'page' => ZipExportPage::fromArray($metadataArray),
default => null,
};
}
}

137
app/Exports/ImportRepo.php Normal file
View File

@ -0,0 +1,137 @@
<?php
namespace BookStack\Exports;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Exceptions\FileUploadException;
use BookStack\Exceptions\ZipExportException;
use BookStack\Exceptions\ZipImportException;
use BookStack\Exceptions\ZipValidationException;
use BookStack\Exports\ZipExports\Models\ZipExportBook;
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Exports\ZipExports\ZipExportReader;
use BookStack\Exports\ZipExports\ZipExportValidator;
use BookStack\Exports\ZipExports\ZipImportRunner;
use BookStack\Facades\Activity;
use BookStack\Uploads\FileStorage;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImportRepo
{
public function __construct(
protected FileStorage $storage,
protected ZipImportRunner $importer,
protected EntityQueries $entityQueries,
) {
}
/**
* @return Collection<Import>
*/
public function getVisibleImports(): Collection
{
$query = Import::query();
if (!userCan('settings-manage')) {
$query->where('created_by', user()->id);
}
return $query->get();
}
public function findVisible(int $id): Import
{
$query = Import::query();
if (!userCan('settings-manage')) {
$query->where('created_by', user()->id);
}
return $query->findOrFail($id);
}
/**
* @throws FileUploadException
* @throws ZipValidationException
* @throws ZipExportException
*/
public function storeFromUpload(UploadedFile $file): Import
{
$zipPath = $file->getRealPath();
$reader = new ZipExportReader($zipPath);
$errors = (new ZipExportValidator($reader))->validate();
if ($errors) {
throw new ZipValidationException($errors);
}
$exportModel = $reader->decodeDataToExportModel();
$import = new Import();
$import->type = match (get_class($exportModel)) {
ZipExportPage::class => 'page',
ZipExportChapter::class => 'chapter',
ZipExportBook::class => 'book',
};
$import->name = $exportModel->name;
$import->created_by = user()->id;
$import->size = filesize($zipPath);
$exportModel->metadataOnly();
$import->metadata = json_encode($exportModel);
$path = $this->storage->uploadFile(
$file,
'uploads/files/imports/',
'',
'zip'
);
$import->path = $path;
$import->save();
Activity::add(ActivityType::IMPORT_CREATE, $import);
return $import;
}
/**
* @throws ZipImportException
*/
public function runImport(Import $import, ?string $parent = null): Entity
{
$parentModel = null;
if ($import->type === 'page' || $import->type === 'chapter') {
$parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null;
}
DB::beginTransaction();
try {
$model = $this->importer->run($import, $parentModel);
} catch (ZipImportException $e) {
DB::rollBack();
$this->importer->revertStoredFiles();
throw $e;
}
DB::commit();
$this->deleteImport($import);
Activity::add(ActivityType::IMPORT_RUN, $import);
return $model;
}
public function deleteImport(Import $import): void
{
$this->storage->delete($import->path);
$import->delete();
Activity::add(ActivityType::IMPORT_DELETE, $import);
}
}

View File

@ -1,10 +1,10 @@
<?php
namespace BookStack\Entities\Tools;
namespace BookStack\Exports;
use BookStack\Exceptions\PdfExportException;
use Knp\Snappy\Pdf as SnappyPdf;
use Dompdf\Dompdf;
use Knp\Snappy\Pdf as SnappyPdf;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
@ -90,18 +90,28 @@ class PdfGenerator
$process = Process::fromShellCommandline($command);
$process->setTimeout($timeout);
$cleanup = function () use ($inputHtml, $outputPdf) {
foreach ([$inputHtml, $outputPdf] as $file) {
if (file_exists($file)) {
unlink($file);
}
}
};
try {
$process->run();
} catch (ProcessTimedOutException $e) {
$cleanup();
throw new PdfExportException("PDF Export via command failed due to timeout at {$timeout} second(s)");
}
if (!$process->isSuccessful()) {
$cleanup();
throw new PdfExportException("PDF Export via command failed with exit code {$process->getExitCode()}, stdout: {$process->getOutput()}, stderr: {$process->getErrorOutput()}");
}
$pdfContents = file_get_contents($outputPdf);
unlink($outputPdf);
$cleanup();
if ($pdfContents === false) {
throw new PdfExportException("PDF Export via command failed, unable to read PDF output file");

View File

@ -0,0 +1,66 @@
<?php
namespace BookStack\Exports\ZipExports\Models;
use BookStack\Exports\ZipExports\ZipExportFiles;
use BookStack\Exports\ZipExports\ZipValidationHelper;
use BookStack\Uploads\Attachment;
class ZipExportAttachment extends ZipExportModel
{
public ?int $id = null;
public string $name;
public ?string $link = null;
public ?string $file = null;
public function metadataOnly(): void
{
$this->link = $this->file = null;
}
public static function fromModel(Attachment $model, ZipExportFiles $files): self
{
$instance = new self();
$instance->id = $model->id;
$instance->name = $model->name;
if ($model->external) {
$instance->link = $model->path;
} else {
$instance->file = $files->referenceForAttachment($model);
}
return $instance;
}
public static function fromModelArray(array $attachmentArray, ZipExportFiles $files): array
{
return array_values(array_map(function (Attachment $attachment) use ($files) {
return self::fromModel($attachment, $files);
}, $attachmentArray));
}
public static function validate(ZipValidationHelper $context, array $data): array
{
$rules = [
'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')],
'name' => ['required', 'string', 'min:1'],
'link' => ['required_without:file', 'nullable', 'string'],
'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()],
];
return $context->validateData($data, $rules);
}
public static function fromArray(array $data): self
{
$model = new self();
$model->id = $data['id'] ?? null;
$model->name = $data['name'];
$model->link = $data['link'] ?? null;
$model->file = $data['file'] ?? null;
return $model;
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace BookStack\Exports\ZipExports\Models;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exports\ZipExports\ZipExportFiles;
use BookStack\Exports\ZipExports\ZipValidationHelper;
class ZipExportBook extends ZipExportModel
{
public ?int $id = null;
public string $name;
public ?string $description_html = null;
public ?string $cover = null;
/** @var ZipExportChapter[] */
public array $chapters = [];
/** @var ZipExportPage[] */
public array $pages = [];
/** @var ZipExportTag[] */
public array $tags = [];
public function metadataOnly(): void
{
$this->description_html = $this->cover = null;
foreach ($this->chapters as $chapter) {
$chapter->metadataOnly();
}
foreach ($this->pages as $page) {
$page->metadataOnly();
}
foreach ($this->tags as $tag) {
$tag->metadataOnly();
}
}
public function children(): array
{
$children = [
...$this->pages,
...$this->chapters,
];
usort($children, function ($a, $b) {
return ($a->priority ?? 0) - ($b->priority ?? 0);
});
return $children;
}
public static function fromModel(Book $model, ZipExportFiles $files): self
{
$instance = new self();
$instance->id = $model->id;
$instance->name = $model->name;
$instance->description_html = $model->descriptionHtml();
if ($model->cover) {
$instance->cover = $files->referenceForImage($model->cover);
}
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
$chapters = [];
$pages = [];
$children = $model->getDirectVisibleChildren()->all();
foreach ($children as $child) {
if ($child instanceof Chapter) {
$chapters[] = $child;
} else if ($child instanceof Page && !$child->draft) {
$pages[] = $child;
}
}
$instance->pages = ZipExportPage::fromModelArray($pages, $files);
$instance->chapters = ZipExportChapter::fromModelArray($chapters, $files);
return $instance;
}
public static function validate(ZipValidationHelper $context, array $data): array
{
$rules = [
'id' => ['nullable', 'int', $context->uniqueIdRule('book')],
'name' => ['required', 'string', 'min:1'],
'description_html' => ['nullable', 'string'],
'cover' => ['nullable', 'string', $context->fileReferenceRule()],
'tags' => ['array'],
'pages' => ['array'],
'chapters' => ['array'],
];
$errors = $context->validateData($data, $rules);
$errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class);
$errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class);
$errors['chapters'] = $context->validateRelations($data['chapters'] ?? [], ZipExportChapter::class);
return $errors;
}
public static function fromArray(array $data): self
{
$model = new self();
$model->id = $data['id'] ?? null;
$model->name = $data['name'];
$model->description_html = $data['description_html'] ?? null;
$model->cover = $data['cover'] ?? null;
$model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
$model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []);
$model->chapters = ZipExportChapter::fromManyArray($data['chapters'] ?? []);
return $model;
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace BookStack\Exports\ZipExports\Models;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exports\ZipExports\ZipExportFiles;
use BookStack\Exports\ZipExports\ZipValidationHelper;
class ZipExportChapter extends ZipExportModel
{
public ?int $id = null;
public string $name;
public ?string $description_html = null;
public ?int $priority = null;
/** @var ZipExportPage[] */
public array $pages = [];
/** @var ZipExportTag[] */
public array $tags = [];
public function metadataOnly(): void
{
$this->description_html = null;
foreach ($this->pages as $page) {
$page->metadataOnly();
}
foreach ($this->tags as $tag) {
$tag->metadataOnly();
}
}
public function children(): array
{
return $this->pages;
}
public static function fromModel(Chapter $model, ZipExportFiles $files): self
{
$instance = new self();
$instance->id = $model->id;
$instance->name = $model->name;
$instance->description_html = $model->descriptionHtml();
$instance->priority = $model->priority;
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
$pages = $model->getVisiblePages()->filter(fn (Page $page) => !$page->draft)->all();
$instance->pages = ZipExportPage::fromModelArray($pages, $files);
return $instance;
}
/**
* @param Chapter[] $chapterArray
* @return self[]
*/
public static function fromModelArray(array $chapterArray, ZipExportFiles $files): array
{
return array_values(array_map(function (Chapter $chapter) use ($files) {
return self::fromModel($chapter, $files);
}, $chapterArray));
}
public static function validate(ZipValidationHelper $context, array $data): array
{
$rules = [
'id' => ['nullable', 'int', $context->uniqueIdRule('chapter')],
'name' => ['required', 'string', 'min:1'],
'description_html' => ['nullable', 'string'],
'priority' => ['nullable', 'int'],
'tags' => ['array'],
'pages' => ['array'],
];
$errors = $context->validateData($data, $rules);
$errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class);
$errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class);
return $errors;
}
public static function fromArray(array $data): self
{
$model = new self();
$model->id = $data['id'] ?? null;
$model->name = $data['name'];
$model->description_html = $data['description_html'] ?? null;
$model->priority = isset($data['priority']) ? intval($data['priority']) : null;
$model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
$model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []);
return $model;
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace BookStack\Exports\ZipExports\Models;
use BookStack\Exports\ZipExports\ZipExportFiles;
use BookStack\Exports\ZipExports\ZipValidationHelper;
use BookStack\Uploads\Image;
use Illuminate\Validation\Rule;
class ZipExportImage extends ZipExportModel
{
public ?int $id = null;
public string $name;
public string $file;
public string $type;
public static function fromModel(Image $model, ZipExportFiles $files): self
{
$instance = new self();
$instance->id = $model->id;
$instance->name = $model->name;
$instance->type = $model->type;
$instance->file = $files->referenceForImage($model);
return $instance;
}
public function metadataOnly(): void
{
//
}
public static function validate(ZipValidationHelper $context, array $data): array
{
$acceptedImageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
$rules = [
'id' => ['nullable', 'int', $context->uniqueIdRule('image')],
'name' => ['required', 'string', 'min:1'],
'file' => ['required', 'string', $context->fileReferenceRule($acceptedImageTypes)],
'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])],
];
return $context->validateData($data, $rules);
}
public static function fromArray(array $data): self
{
$model = new self();
$model->id = $data['id'] ?? null;
$model->name = $data['name'];
$model->file = $data['file'];
$model->type = $data['type'];
return $model;
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace BookStack\Exports\ZipExports\Models;
use BookStack\Exports\ZipExports\ZipValidationHelper;
use JsonSerializable;
abstract class ZipExportModel implements JsonSerializable
{
/**
* Handle the serialization to JSON.
* For these exports, we filter out optional (represented as nullable) fields
* just to clean things up and prevent confusion to avoid null states in the
* resulting export format itself.
*/
public function jsonSerialize(): array
{
$publicProps = get_object_vars(...)->__invoke($this);
return array_filter($publicProps, fn ($value) => $value !== null);
}
/**
* Validate the given array of data intended for this model.
* Return an array of validation errors messages.
* Child items can be considered in the validation result by returning a keyed
* item in the array for its own validation messages.
*/
abstract public static function validate(ZipValidationHelper $context, array $data): array;
/**
* Decode the array of data into this export model.
*/
abstract public static function fromArray(array $data): self;
/**
* Decode an array of array data into an array of export models.
* @param array[] $data
* @return self[]
*/
public static function fromManyArray(array $data): array
{
$results = [];
foreach ($data as $item) {
$results[] = static::fromArray($item);
}
return $results;
}
/**
* Remove additional content in this model to reduce it down
* to just essential id/name values for identification.
*
* The result of this may be something that does not pass validation, but is
* simple for the purpose of creating a contents.
*/
abstract public function metadataOnly(): void;
}

View File

@ -0,0 +1,104 @@
<?php
namespace BookStack\Exports\ZipExports\Models;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\PageContent;
use BookStack\Exports\ZipExports\ZipExportFiles;
use BookStack\Exports\ZipExports\ZipValidationHelper;
class ZipExportPage extends ZipExportModel
{
public ?int $id = null;
public string $name;
public ?string $html = null;
public ?string $markdown = null;
public ?int $priority = null;
/** @var ZipExportAttachment[] */
public array $attachments = [];
/** @var ZipExportImage[] */
public array $images = [];
/** @var ZipExportTag[] */
public array $tags = [];
public function metadataOnly(): void
{
$this->html = $this->markdown = null;
foreach ($this->attachments as $attachment) {
$attachment->metadataOnly();
}
foreach ($this->images as $image) {
$image->metadataOnly();
}
foreach ($this->tags as $tag) {
$tag->metadataOnly();
}
}
public static function fromModel(Page $model, ZipExportFiles $files): self
{
$instance = new self();
$instance->id = $model->id;
$instance->name = $model->name;
$instance->html = (new PageContent($model))->render();
$instance->priority = $model->priority;
if (!empty($model->markdown)) {
$instance->markdown = $model->markdown;
}
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
$instance->attachments = ZipExportAttachment::fromModelArray($model->attachments()->get()->all(), $files);
return $instance;
}
/**
* @param Page[] $pageArray
* @return self[]
*/
public static function fromModelArray(array $pageArray, ZipExportFiles $files): array
{
return array_values(array_map(function (Page $page) use ($files) {
return self::fromModel($page, $files);
}, $pageArray));
}
public static function validate(ZipValidationHelper $context, array $data): array
{
$rules = [
'id' => ['nullable', 'int', $context->uniqueIdRule('page')],
'name' => ['required', 'string', 'min:1'],
'html' => ['nullable', 'string'],
'markdown' => ['nullable', 'string'],
'priority' => ['nullable', 'int'],
'attachments' => ['array'],
'images' => ['array'],
'tags' => ['array'],
];
$errors = $context->validateData($data, $rules);
$errors['attachments'] = $context->validateRelations($data['attachments'] ?? [], ZipExportAttachment::class);
$errors['images'] = $context->validateRelations($data['images'] ?? [], ZipExportImage::class);
$errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class);
return $errors;
}
public static function fromArray(array $data): self
{
$model = new self();
$model->id = $data['id'] ?? null;
$model->name = $data['name'];
$model->html = $data['html'] ?? null;
$model->markdown = $data['markdown'] ?? null;
$model->priority = isset($data['priority']) ? intval($data['priority']) : null;
$model->attachments = ZipExportAttachment::fromManyArray($data['attachments'] ?? []);
$model->images = ZipExportImage::fromManyArray($data['images'] ?? []);
$model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
return $model;
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace BookStack\Exports\ZipExports\Models;
use BookStack\Activity\Models\Tag;
use BookStack\Exports\ZipExports\ZipValidationHelper;
class ZipExportTag extends ZipExportModel
{
public string $name;
public ?string $value = null;
public function metadataOnly(): void
{
$this->value = null;
}
public static function fromModel(Tag $model): self
{
$instance = new self();
$instance->name = $model->name;
$instance->value = $model->value;
return $instance;
}
public static function fromModelArray(array $tagArray): array
{
return array_values(array_map(self::fromModel(...), $tagArray));
}
public static function validate(ZipValidationHelper $context, array $data): array
{
$rules = [
'name' => ['required', 'string', 'min:1'],
'value' => ['nullable', 'string'],
];
return $context->validateData($data, $rules);
}
public static function fromArray(array $data): self
{
$model = new self();
$model->name = $data['name'];
$model->value = $data['value'] ?? null;
return $model;
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace BookStack\Exports\ZipExports;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\ZipExportException;
use BookStack\Exports\ZipExports\Models\ZipExportBook;
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportPage;
use ZipArchive;
class ZipExportBuilder
{
protected array $data = [];
public function __construct(
protected ZipExportFiles $files,
protected ZipExportReferences $references,
) {
}
/**
* @throws ZipExportException
*/
public function buildForPage(Page $page): string
{
$exportPage = ZipExportPage::fromModel($page, $this->files);
$this->data['page'] = $exportPage;
$this->references->addPage($exportPage);
return $this->build();
}
/**
* @throws ZipExportException
*/
public function buildForChapter(Chapter $chapter): string
{
$exportChapter = ZipExportChapter::fromModel($chapter, $this->files);
$this->data['chapter'] = $exportChapter;
$this->references->addChapter($exportChapter);
return $this->build();
}
/**
* @throws ZipExportException
*/
public function buildForBook(Book $book): string
{
$exportBook = ZipExportBook::fromModel($book, $this->files);
$this->data['book'] = $exportBook;
$this->references->addBook($exportBook);
return $this->build();
}
/**
* @throws ZipExportException
*/
protected function build(): string
{
$this->references->buildReferences($this->files);
$this->data['exported_at'] = date(DATE_ATOM);
$this->data['instance'] = [
'id' => setting('instance-id', ''),
'version' => trim(file_get_contents(base_path('version'))),
];
$zipFile = tempnam(sys_get_temp_dir(), 'bszip-');
$zip = new ZipArchive();
$opened = $zip->open($zipFile, ZipArchive::CREATE);
if ($opened !== true) {
throw new ZipExportException('Failed to create zip file for export.');
}
$zip->addFromString('data.json', json_encode($this->data));
$zip->addEmptyDir('files');
$toRemove = [];
$addedNames = [];
try {
$this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove, &$addedNames) {
$entryName = "files/$fileRef";
$zip->addFile($filePath, $entryName);
$toRemove[] = $filePath;
$addedNames[] = $entryName;
});
} catch (\Exception $exception) {
// Cleanup the files we've processed so far and respond back with error
foreach ($toRemove as $file) {
unlink($file);
}
foreach ($addedNames as $name) {
$zip->deleteName($name);
}
$zip->close();
unlink($zipFile);
throw new ZipExportException("Failed to add files for ZIP export, received error: " . $exception->getMessage());
}
$zip->close();
foreach ($toRemove as $file) {
unlink($file);
}
return $zipFile;
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace BookStack\Exports\ZipExports;
use BookStack\Uploads\Attachment;
use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
use Illuminate\Support\Str;
class ZipExportFiles
{
/**
* References for attachments by attachment ID.
* @var array<int, string>
*/
protected array $attachmentRefsById = [];
/**
* References for images by image ID.
* @var array<int, string>
*/
protected array $imageRefsById = [];
public function __construct(
protected AttachmentService $attachmentService,
protected ImageService $imageService,
) {
}
/**
* Gain a reference to the given attachment instance.
* This is expected to be a file-based attachment that the user
* has visibility of, no permission/access checks are performed here.
*/
public function referenceForAttachment(Attachment $attachment): string
{
if (isset($this->attachmentRefsById[$attachment->id])) {
return $this->attachmentRefsById[$attachment->id];
}
$existingFiles = $this->getAllFileNames();
do {
$fileName = Str::random(20) . '.' . $attachment->extension;
} while (in_array($fileName, $existingFiles));
$this->attachmentRefsById[$attachment->id] = $fileName;
return $fileName;
}
/**
* Gain a reference to the given image instance.
* This is expected to be an image that the user has visibility of,
* no permission/access checks are performed here.
*/
public function referenceForImage(Image $image): string
{
if (isset($this->imageRefsById[$image->id])) {
return $this->imageRefsById[$image->id];
}
$existingFiles = $this->getAllFileNames();
$extension = pathinfo($image->path, PATHINFO_EXTENSION);
do {
$fileName = Str::random(20) . '.' . $extension;
} while (in_array($fileName, $existingFiles));
$this->imageRefsById[$image->id] = $fileName;
return $fileName;
}
protected function getAllFileNames(): array
{
return array_merge(
array_values($this->attachmentRefsById),
array_values($this->imageRefsById),
);
}
/**
* Extract each of the ZIP export tracked files.
* Calls the given callback for each tracked file, passing a temporary
* file reference of the file contents, and the zip-local tracked reference.
*/
public function extractEach(callable $callback): void
{
foreach ($this->attachmentRefsById as $attachmentId => $ref) {
$attachment = Attachment::query()->find($attachmentId);
$stream = $this->attachmentService->streamAttachmentFromStorage($attachment);
$tmpFile = tempnam(sys_get_temp_dir(), 'bszipfile-');
$tmpFileStream = fopen($tmpFile, 'w');
stream_copy_to_stream($stream, $tmpFileStream);
$callback($tmpFile, $ref);
}
foreach ($this->imageRefsById as $imageId => $ref) {
$image = Image::query()->find($imageId);
$stream = $this->imageService->getImageStream($image);
$tmpFile = tempnam(sys_get_temp_dir(), 'bszipimage-');
$tmpFileStream = fopen($tmpFile, 'w');
stream_copy_to_stream($stream, $tmpFileStream);
$callback($tmpFile, $ref);
}
}
}

View File

@ -0,0 +1,111 @@
<?php
namespace BookStack\Exports\ZipExports;
use BookStack\Exceptions\ZipExportException;
use BookStack\Exports\ZipExports\Models\ZipExportBook;
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Util\WebSafeMimeSniffer;
use ZipArchive;
class ZipExportReader
{
protected ZipArchive $zip;
protected bool $open = false;
public function __construct(
protected string $zipPath,
) {
$this->zip = new ZipArchive();
}
/**
* @throws ZipExportException
*/
protected function open(): void
{
if ($this->open) {
return;
}
// Validate file exists
if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) {
throw new ZipExportException(trans('errors.import_zip_cant_read'));
}
// Validate file is valid zip
$opened = $this->zip->open($this->zipPath, ZipArchive::RDONLY);
if ($opened !== true) {
throw new ZipExportException(trans('errors.import_zip_cant_read'));
}
$this->open = true;
}
public function close(): void
{
if ($this->open) {
$this->zip->close();
$this->open = false;
}
}
/**
* @throws ZipExportException
*/
public function readData(): array
{
$this->open();
// Validate json data exists, including metadata
$jsonData = $this->zip->getFromName('data.json') ?: '';
$importData = json_decode($jsonData, true);
if (!$importData) {
throw new ZipExportException(trans('errors.import_zip_cant_decode_data'));
}
return $importData;
}
public function fileExists(string $fileName): bool
{
return $this->zip->statName("files/{$fileName}") !== false;
}
/**
* @return false|resource
*/
public function streamFile(string $fileName)
{
return $this->zip->getStream("files/{$fileName}");
}
/**
* Sniff the mime type from the file of given name.
*/
public function sniffFileMime(string $fileName): string
{
$stream = $this->streamFile($fileName);
$sniffContent = fread($stream, 2000);
return (new WebSafeMimeSniffer())->sniff($sniffContent);
}
/**
* @throws ZipExportException
*/
public function decodeDataToExportModel(): ZipExportBook|ZipExportChapter|ZipExportPage
{
$data = $this->readData();
if (isset($data['book'])) {
return ZipExportBook::fromArray($data['book']);
} else if (isset($data['chapter'])) {
return ZipExportChapter::fromArray($data['chapter']);
} else if (isset($data['page'])) {
return ZipExportPage::fromArray($data['page']);
}
throw new ZipExportException("Could not identify content in ZIP file data.");
}
}

View File

@ -0,0 +1,159 @@
<?php
namespace BookStack\Exports\ZipExports;
use BookStack\App\Model;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exports\ZipExports\Models\ZipExportAttachment;
use BookStack\Exports\ZipExports\Models\ZipExportBook;
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportImage;
use BookStack\Exports\ZipExports\Models\ZipExportModel;
use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Uploads\Attachment;
use BookStack\Uploads\Image;
class ZipExportReferences
{
/** @var ZipExportPage[] */
protected array $pages = [];
/** @var ZipExportChapter[] */
protected array $chapters = [];
/** @var ZipExportBook[] */
protected array $books = [];
/** @var ZipExportAttachment[] */
protected array $attachments = [];
/** @var ZipExportImage[] */
protected array $images = [];
public function __construct(
protected ZipReferenceParser $parser,
) {
}
public function addPage(ZipExportPage $page): void
{
if ($page->id) {
$this->pages[$page->id] = $page;
}
foreach ($page->attachments as $attachment) {
if ($attachment->id) {
$this->attachments[$attachment->id] = $attachment;
}
}
}
public function addChapter(ZipExportChapter $chapter): void
{
if ($chapter->id) {
$this->chapters[$chapter->id] = $chapter;
}
foreach ($chapter->pages as $page) {
$this->addPage($page);
}
}
public function addBook(ZipExportBook $book): void
{
if ($book->id) {
$this->books[$book->id] = $book;
}
foreach ($book->pages as $page) {
$this->addPage($page);
}
foreach ($book->chapters as $chapter) {
$this->addChapter($chapter);
}
}
public function buildReferences(ZipExportFiles $files): void
{
$createHandler = function (ZipExportModel $zipModel) use ($files) {
return function (Model $model) use ($files, $zipModel) {
return $this->handleModelReference($model, $zipModel, $files);
};
};
// Parse page content first
foreach ($this->pages as $page) {
$handler = $createHandler($page);
$page->html = $this->parser->parseLinks($page->html ?? '', $handler);
if ($page->markdown) {
$page->markdown = $this->parser->parseLinks($page->markdown, $handler);
}
}
// Parse chapter description HTML
foreach ($this->chapters as $chapter) {
if ($chapter->description_html) {
$handler = $createHandler($chapter);
$chapter->description_html = $this->parser->parseLinks($chapter->description_html, $handler);
}
}
// Parse book description HTML
foreach ($this->books as $book) {
if ($book->description_html) {
$handler = $createHandler($book);
$book->description_html = $this->parser->parseLinks($book->description_html, $handler);
}
}
}
protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string
{
// Handle attachment references
// No permission check needed here since they would only already exist in this
// reference context if already allowed via their entity access.
if ($model instanceof Attachment) {
if (isset($this->attachments[$model->id])) {
return "[[bsexport:attachment:{$model->id}]]";
}
return null;
}
// Handle image references
if ($model instanceof Image) {
// Only handle gallery and drawio images
if ($model->type !== 'gallery' && $model->type !== 'drawio') {
return null;
}
// Handle simple links outside of page content
if (!($exportModel instanceof ZipExportPage) && isset($this->images[$model->id])) {
return "[[bsexport:image:{$model->id}]]";
}
// Find and include images if in visibility
$page = $model->getPage();
if ($page && userCan('view', $page)) {
if (!isset($this->images[$model->id])) {
$exportImage = ZipExportImage::fromModel($model, $files);
$this->images[$model->id] = $exportImage;
$exportModel->images[] = $exportImage;
}
return "[[bsexport:image:{$model->id}]]";
}
return null;
}
// Handle entity references
if ($model instanceof Book && isset($this->books[$model->id])) {
return "[[bsexport:book:{$model->id}]]";
} else if ($model instanceof Chapter && isset($this->chapters[$model->id])) {
return "[[bsexport:chapter:{$model->id}]]";
} else if ($model instanceof Page && isset($this->pages[$model->id])) {
return "[[bsexport:page:{$model->id}]]";
}
return null;
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace BookStack\Exports\ZipExports;
use BookStack\Exceptions\ZipExportException;
use BookStack\Exports\ZipExports\Models\ZipExportBook;
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportPage;
class ZipExportValidator
{
public function __construct(
protected ZipExportReader $reader,
) {
}
public function validate(): array
{
try {
$importData = $this->reader->readData();
} catch (ZipExportException $exception) {
return ['format' => $exception->getMessage()];
}
$helper = new ZipValidationHelper($this->reader);
if (isset($importData['book'])) {
$modelErrors = ZipExportBook::validate($helper, $importData['book']);
$keyPrefix = 'book';
} else if (isset($importData['chapter'])) {
$modelErrors = ZipExportChapter::validate($helper, $importData['chapter']);
$keyPrefix = 'chapter';
} else if (isset($importData['page'])) {
$modelErrors = ZipExportPage::validate($helper, $importData['page']);
$keyPrefix = 'page';
} else {
return ['format' => trans('errors.import_zip_no_data')];
}
return $this->flattenModelErrors($modelErrors, $keyPrefix);
}
protected function flattenModelErrors(array $errors, string $keyPrefix): array
{
$flattened = [];
foreach ($errors as $key => $error) {
if (is_array($error)) {
$flattened = array_merge($flattened, $this->flattenModelErrors($error, $keyPrefix . '.' . $key));
} else {
$flattened[$keyPrefix . '.' . $key] = $error;
}
}
return $flattened;
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace BookStack\Exports\ZipExports;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class ZipFileReferenceRule implements ValidationRule
{
public function __construct(
protected ZipValidationHelper $context,
protected array $acceptedMimes,
) {
}
/**
* @inheritDoc
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!$this->context->zipReader->fileExists($value)) {
$fail('validation.zip_file')->translate();
}
if (!empty($this->acceptedMimes)) {
$fileMime = $this->context->zipReader->sniffFileMime($value);
if (!in_array($fileMime, $this->acceptedMimes)) {
$fail('validation.zip_file_mime')->translate([
'attribute' => $attribute,
'validTypes' => implode(',', $this->acceptedMimes),
'foundType' => $fileMime
]);
}
}
}
}

View File

@ -0,0 +1,161 @@
<?php
namespace BookStack\Exports\ZipExports;
use BookStack\App\Model;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BaseRepo;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exports\ZipExports\Models\ZipExportBook;
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Uploads\Attachment;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageResizer;
class ZipImportReferences
{
/** @var Page[] */
protected array $pages = [];
/** @var Chapter[] */
protected array $chapters = [];
/** @var Book[] */
protected array $books = [];
/** @var Attachment[] */
protected array $attachments = [];
/** @var Image[] */
protected array $images = [];
/** @var array<string, Model> */
protected array $referenceMap = [];
/** @var array<int, ZipExportPage> */
protected array $zipExportPageMap = [];
/** @var array<int, ZipExportChapter> */
protected array $zipExportChapterMap = [];
/** @var array<int, ZipExportBook> */
protected array $zipExportBookMap = [];
public function __construct(
protected ZipReferenceParser $parser,
protected BaseRepo $baseRepo,
protected PageRepo $pageRepo,
protected ImageResizer $imageResizer,
) {
}
protected function addReference(string $type, Model $model, ?int $importId): void
{
if ($importId) {
$key = $type . ':' . $importId;
$this->referenceMap[$key] = $model;
}
}
public function addPage(Page $page, ZipExportPage $exportPage): void
{
$this->pages[] = $page;
$this->zipExportPageMap[$page->id] = $exportPage;
$this->addReference('page', $page, $exportPage->id);
}
public function addChapter(Chapter $chapter, ZipExportChapter $exportChapter): void
{
$this->chapters[] = $chapter;
$this->zipExportChapterMap[$chapter->id] = $exportChapter;
$this->addReference('chapter', $chapter, $exportChapter->id);
}
public function addBook(Book $book, ZipExportBook $exportBook): void
{
$this->books[] = $book;
$this->zipExportBookMap[$book->id] = $exportBook;
$this->addReference('book', $book, $exportBook->id);
}
public function addAttachment(Attachment $attachment, ?int $importId): void
{
$this->attachments[] = $attachment;
$this->addReference('attachment', $attachment, $importId);
}
public function addImage(Image $image, ?int $importId): void
{
$this->images[] = $image;
$this->addReference('image', $image, $importId);
}
protected function handleReference(string $type, int $id): ?string
{
$key = $type . ':' . $id;
$model = $this->referenceMap[$key] ?? null;
if ($model instanceof Entity) {
return $model->getUrl();
} else if ($model instanceof Image) {
if ($model->type === 'gallery') {
$this->imageResizer->loadGalleryThumbnailsForImage($model, false);
return $model->thumbs['display'] ?? $model->url;
}
return $model->url;
} else if ($model instanceof Attachment) {
return $model->getUrl(false);
}
return null;
}
public function replaceReferences(): void
{
foreach ($this->books as $book) {
$exportBook = $this->zipExportBookMap[$book->id];
$content = $exportBook->description_html ?? '';
$parsed = $this->parser->parseReferences($content, $this->handleReference(...));
$this->baseRepo->update($book, [
'description_html' => $parsed,
]);
}
foreach ($this->chapters as $chapter) {
$exportChapter = $this->zipExportChapterMap[$chapter->id];
$content = $exportChapter->description_html ?? '';
$parsed = $this->parser->parseReferences($content, $this->handleReference(...));
$this->baseRepo->update($chapter, [
'description_html' => $parsed,
]);
}
foreach ($this->pages as $page) {
$exportPage = $this->zipExportPageMap[$page->id];
$contentType = $exportPage->markdown ? 'markdown' : 'html';
$content = $exportPage->markdown ?: ($exportPage->html ?: '');
$parsed = $this->parser->parseReferences($content, $this->handleReference(...));
$this->pageRepo->setContentFromInput($page, [
$contentType => $parsed,
]);
}
}
/**
* @return Image[]
*/
public function images(): array
{
return $this->images;
}
/**
* @return Attachment[]
*/
public function attachments(): array
{
return $this->attachments;
}
}

View File

@ -0,0 +1,364 @@
<?php
namespace BookStack\Exports\ZipExports;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\ZipExportException;
use BookStack\Exceptions\ZipImportException;
use BookStack\Exports\Import;
use BookStack\Exports\ZipExports\Models\ZipExportAttachment;
use BookStack\Exports\ZipExports\Models\ZipExportBook;
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportImage;
use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Exports\ZipExports\Models\ZipExportTag;
use BookStack\Uploads\Attachment;
use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\FileStorage;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
use Illuminate\Http\UploadedFile;
class ZipImportRunner
{
protected array $tempFilesToCleanup = [];
public function __construct(
protected FileStorage $storage,
protected PageRepo $pageRepo,
protected ChapterRepo $chapterRepo,
protected BookRepo $bookRepo,
protected ImageService $imageService,
protected AttachmentService $attachmentService,
protected ZipImportReferences $references,
) {
}
/**
* Run the import.
* Performs re-validation on zip, validation on parent provided, and permissions for importing
* the planned content, before running the import process.
* Returns the top-level entity item which was imported.
* @throws ZipImportException
*/
public function run(Import $import, ?Entity $parent = null): Entity
{
$zipPath = $this->getZipPath($import);
$reader = new ZipExportReader($zipPath);
$errors = (new ZipExportValidator($reader))->validate();
if ($errors) {
throw new ZipImportException([
trans('errors.import_validation_failed'),
...$errors,
]);
}
try {
$exportModel = $reader->decodeDataToExportModel();
} catch (ZipExportException $e) {
throw new ZipImportException([$e->getMessage()]);
}
// Validate parent type
if ($exportModel instanceof ZipExportBook && ($parent !== null)) {
throw new ZipImportException(["Must not have a parent set for a Book import."]);
} else if ($exportModel instanceof ZipExportChapter && !($parent instanceof Book)) {
throw new ZipImportException(["Parent book required for chapter import."]);
} else if ($exportModel instanceof ZipExportPage && !($parent instanceof Book || $parent instanceof Chapter)) {
throw new ZipImportException(["Parent book or chapter required for page import."]);
}
$this->ensurePermissionsPermitImport($exportModel, $parent);
if ($exportModel instanceof ZipExportBook) {
$entity = $this->importBook($exportModel, $reader);
} else if ($exportModel instanceof ZipExportChapter) {
$entity = $this->importChapter($exportModel, $parent, $reader);
} else if ($exportModel instanceof ZipExportPage) {
$entity = $this->importPage($exportModel, $parent, $reader);
} else {
throw new ZipImportException(['No importable data found in import data.']);
}
$this->references->replaceReferences();
$reader->close();
$this->cleanup();
return $entity;
}
/**
* Revert any files which have been stored during this import process.
* Considers files only, and avoids the database under the
* assumption that the database may already have been
* reverted as part of a transaction rollback.
*/
public function revertStoredFiles(): void
{
foreach ($this->references->images() as $image) {
$this->imageService->destroyFileAtPath($image->type, $image->path);
}
foreach ($this->references->attachments() as $attachment) {
if (!$attachment->external) {
$this->attachmentService->deleteFileInStorage($attachment);
}
}
$this->cleanup();
}
protected function cleanup(): void
{
foreach ($this->tempFilesToCleanup as $file) {
unlink($file);
}
$this->tempFilesToCleanup = [];
}
protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book
{
$book = $this->bookRepo->create([
'name' => $exportBook->name,
'description_html' => $exportBook->description_html ?? '',
'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader) : null,
'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
]);
if ($book->cover) {
$this->references->addImage($book->cover, null);
}
$children = [
...$exportBook->chapters,
...$exportBook->pages,
];
usort($children, function (ZipExportPage|ZipExportChapter $a, ZipExportPage|ZipExportChapter $b) {
return ($a->priority ?? 0) - ($b->priority ?? 0);
});
foreach ($children as $child) {
if ($child instanceof ZipExportChapter) {
$this->importChapter($child, $book, $reader);
} else if ($child instanceof ZipExportPage) {
$this->importPage($child, $book, $reader);
}
}
$this->references->addBook($book, $exportBook);
return $book;
}
protected function importChapter(ZipExportChapter $exportChapter, Book $parent, ZipExportReader $reader): Chapter
{
$chapter = $this->chapterRepo->create([
'name' => $exportChapter->name,
'description_html' => $exportChapter->description_html ?? '',
'tags' => $this->exportTagsToInputArray($exportChapter->tags ?? []),
], $parent);
$exportPages = $exportChapter->pages;
usort($exportPages, function (ZipExportPage $a, ZipExportPage $b) {
return ($a->priority ?? 0) - ($b->priority ?? 0);
});
foreach ($exportPages as $exportPage) {
$this->importPage($exportPage, $chapter, $reader);
}
$this->references->addChapter($chapter, $exportChapter);
return $chapter;
}
protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, ZipExportReader $reader): Page
{
$page = $this->pageRepo->getNewDraftPage($parent);
foreach ($exportPage->attachments as $exportAttachment) {
$this->importAttachment($exportAttachment, $page, $reader);
}
foreach ($exportPage->images as $exportImage) {
$this->importImage($exportImage, $page, $reader);
}
$this->pageRepo->publishDraft($page, [
'name' => $exportPage->name,
'markdown' => $exportPage->markdown,
'html' => $exportPage->html,
'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
]);
$this->references->addPage($page, $exportPage);
return $page;
}
protected function importAttachment(ZipExportAttachment $exportAttachment, Page $page, ZipExportReader $reader): Attachment
{
if ($exportAttachment->file) {
$file = $this->zipFileToUploadedFile($exportAttachment->file, $reader);
$attachment = $this->attachmentService->saveNewUpload($file, $page->id);
$attachment->name = $exportAttachment->name;
$attachment->save();
} else {
$attachment = $this->attachmentService->saveNewFromLink(
$exportAttachment->name,
$exportAttachment->link ?? '',
$page->id,
);
}
$this->references->addAttachment($attachment, $exportAttachment->id);
return $attachment;
}
protected function importImage(ZipExportImage $exportImage, Page $page, ZipExportReader $reader): Image
{
$mime = $reader->sniffFileMime($exportImage->file);
$extension = explode('/', $mime)[1];
$file = $this->zipFileToUploadedFile($exportImage->file, $reader);
$image = $this->imageService->saveNewFromUpload(
$file,
$exportImage->type,
$page->id,
null,
null,
true,
$exportImage->name . '.' . $extension,
);
$image->name = $exportImage->name;
$image->save();
$this->references->addImage($image, $exportImage->id);
return $image;
}
protected function exportTagsToInputArray(array $exportTags): array
{
$tags = [];
/** @var ZipExportTag $tag */
foreach ($exportTags as $tag) {
$tags[] = ['name' => $tag->name, 'value' => $tag->value ?? ''];
}
return $tags;
}
protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile
{
$tempPath = tempnam(sys_get_temp_dir(), 'bszipextract');
$fileStream = $reader->streamFile($fileName);
$tempStream = fopen($tempPath, 'wb');
stream_copy_to_stream($fileStream, $tempStream);
fclose($tempStream);
$this->tempFilesToCleanup[] = $tempPath;
return new UploadedFile($tempPath, $fileName);
}
/**
* @throws ZipImportException
*/
protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter|ZipExportBook $exportModel, Book|Chapter|null $parent = null): void
{
$errors = [];
$chapters = [];
$pages = [];
$images = [];
$attachments = [];
if ($exportModel instanceof ZipExportBook) {
if (!userCan('book-create-all')) {
$errors[] = trans('errors.import_perms_books');
}
array_push($pages, ...$exportModel->pages);
array_push($chapters, ...$exportModel->chapters);
} else if ($exportModel instanceof ZipExportChapter) {
$chapters[] = $exportModel;
} else if ($exportModel instanceof ZipExportPage) {
$pages[] = $exportModel;
}
foreach ($chapters as $chapter) {
array_push($pages, ...$chapter->pages);
}
if (count($chapters) > 0) {
$permission = 'chapter-create' . ($parent ? '' : '-all');
if (!userCan($permission, $parent)) {
$errors[] = trans('errors.import_perms_chapters');
}
}
foreach ($pages as $page) {
array_push($attachments, ...$page->attachments);
array_push($images, ...$page->images);
}
if (count($pages) > 0) {
if ($parent) {
if (!userCan('page-create', $parent)) {
$errors[] = trans('errors.import_perms_pages');
}
} else {
$hasPermission = userCan('page-create-all') || userCan('page-create-own');
if (!$hasPermission) {
$errors[] = trans('errors.import_perms_pages');
}
}
}
if (count($images) > 0) {
if (!userCan('image-create-all')) {
$errors[] = trans('errors.import_perms_images');
}
}
if (count($attachments) > 0) {
if (!userCan('attachment-create-all')) {
$errors[] = trans('errors.import_perms_attachments');
}
}
if (count($errors)) {
throw new ZipImportException($errors);
}
}
protected function getZipPath(Import $import): string
{
if (!$this->storage->isRemote()) {
return $this->storage->getSystemPath($import->path);
}
$tempFilePath = tempnam(sys_get_temp_dir(), 'bszip-import-');
$tempFile = fopen($tempFilePath, 'wb');
$stream = $this->storage->getReadStream($import->path);
stream_copy_to_stream($stream, $tempFile);
fclose($tempFile);
$this->tempFilesToCleanup[] = $tempFilePath;
return $tempFilePath;
}
}

View File

@ -0,0 +1,140 @@
<?php
namespace BookStack\Exports\ZipExports;
use BookStack\App\Model;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\References\ModelResolvers\AttachmentModelResolver;
use BookStack\References\ModelResolvers\BookLinkModelResolver;
use BookStack\References\ModelResolvers\ChapterLinkModelResolver;
use BookStack\References\ModelResolvers\CrossLinkModelResolver;
use BookStack\References\ModelResolvers\ImageModelResolver;
use BookStack\References\ModelResolvers\PageLinkModelResolver;
use BookStack\References\ModelResolvers\PagePermalinkModelResolver;
use BookStack\Uploads\ImageStorage;
class ZipReferenceParser
{
/**
* @var CrossLinkModelResolver[]|null
*/
protected ?array $modelResolvers = null;
public function __construct(
protected EntityQueries $queries
) {
}
/**
* Parse and replace references in the given content.
* Calls the handler for each model link detected and replaces the link
* with the handler return value if provided.
* Returns the resulting content with links replaced.
* @param callable(Model):(string|null) $handler
*/
public function parseLinks(string $content, callable $handler): string
{
$linkRegex = $this->getLinkRegex();
$matches = [];
preg_match_all($linkRegex, $content, $matches);
if (count($matches) < 2) {
return $content;
}
foreach ($matches[1] as $link) {
$model = $this->linkToModel($link);
if ($model) {
$result = $handler($model);
if ($result !== null) {
$content = str_replace($link, $result, $content);
}
}
}
return $content;
}
/**
* Parse and replace references in the given content.
* Calls the handler for each reference detected and replaces the link
* with the handler return value if provided.
* Returns the resulting content string with references replaced.
* @param callable(string $type, int $id):(string|null) $handler
*/
public function parseReferences(string $content, callable $handler): string
{
$referenceRegex = '/\[\[bsexport:([a-z]+):(\d+)]]/';
$matches = [];
preg_match_all($referenceRegex, $content, $matches);
if (count($matches) < 3) {
return $content;
}
for ($i = 0; $i < count($matches[0]); $i++) {
$referenceText = $matches[0][$i];
$type = strtolower($matches[1][$i]);
$id = intval($matches[2][$i]);
$result = $handler($type, $id);
if ($result !== null) {
$content = str_replace($referenceText, $result, $content);
}
}
return $content;
}
/**
* Attempt to resolve the given link to a model using the instance model resolvers.
*/
protected function linkToModel(string $link): ?Model
{
foreach ($this->getModelResolvers() as $resolver) {
$model = $resolver->resolve($link);
if (!is_null($model)) {
return $model;
}
}
return null;
}
protected function getModelResolvers(): array
{
if (isset($this->modelResolvers)) {
return $this->modelResolvers;
}
$this->modelResolvers = [
new PagePermalinkModelResolver($this->queries->pages),
new PageLinkModelResolver($this->queries->pages),
new ChapterLinkModelResolver($this->queries->chapters),
new BookLinkModelResolver($this->queries->books),
new ImageModelResolver(),
new AttachmentModelResolver(),
];
return $this->modelResolvers;
}
/**
* Build the regex to identify links we should handle in content.
*/
protected function getLinkRegex(): string
{
$urls = [rtrim(url('/'), '/')];
$imageUrl = rtrim(ImageStorage::getPublicUrl('/'), '/');
if ($urls[0] !== $imageUrl) {
$urls[] = $imageUrl;
}
$urlBaseRegex = implode('|', array_map(function ($url) {
return preg_quote($url, '/');
}, $urls));
return "/(({$urlBaseRegex}).*?)[\\t\\n\\f>\"'=?#()]/";
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace BookStack\Exports\ZipExports;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class ZipUniqueIdRule implements ValidationRule
{
public function __construct(
protected ZipValidationHelper $context,
protected string $modelType,
) {
}
/**
* @inheritDoc
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($this->context->hasIdBeenUsed($this->modelType, $value)) {
$fail('validation.zip_unique')->translate(['attribute' => $attribute]);
}
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace BookStack\Exports\ZipExports;
use BookStack\Exports\ZipExports\Models\ZipExportModel;
use Illuminate\Validation\Factory;
class ZipValidationHelper
{
protected Factory $validationFactory;
/**
* Local store of validated IDs (in format "<type>:<id>". Example: "book:2")
* which we can use to check uniqueness.
* @var array<string, bool>
*/
protected array $validatedIds = [];
public function __construct(
public ZipExportReader $zipReader,
) {
$this->validationFactory = app(Factory::class);
}
public function validateData(array $data, array $rules): array
{
$messages = $this->validationFactory->make($data, $rules)->errors()->messages();
foreach ($messages as $key => $message) {
$messages[$key] = implode("\n", $message);
}
return $messages;
}
public function fileReferenceRule(array $acceptedMimes = []): ZipFileReferenceRule
{
return new ZipFileReferenceRule($this, $acceptedMimes);
}
public function uniqueIdRule(string $type): ZipUniqueIdRule
{
return new ZipUniqueIdRule($this, $type);
}
public function hasIdBeenUsed(string $type, mixed $id): bool
{
$key = $type . ':' . $id;
if (isset($this->validatedIds[$key])) {
return true;
}
$this->validatedIds[$key] = true;
return false;
}
/**
* Validate an array of relation data arrays that are expected
* to be for the given ZipExportModel.
* @param class-string<ZipExportModel> $model
*/
public function validateRelations(array $relations, string $model): array
{
$results = [];
foreach ($relations as $key => $relationData) {
if (is_array($relationData)) {
$results[$key] = $model::validate($this, $relationData);
} else {
$results[$key] = [trans('validation.zip_model_expected', ['type' => gettype($relationData)])];
}
}
return $results;
}
}

View File

@ -152,10 +152,8 @@ abstract class Controller extends BaseController
/**
* Log an activity in the system.
*
* @param string|Loggable $detail
*/
protected function logActivity(string $type, $detail = ''): void
protected function logActivity(string $type, string|Loggable $detail = ''): void
{
Activity::add($type, $detail);
}

View File

@ -9,7 +9,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
class DownloadResponseFactory
{
public function __construct(
protected Request $request
protected Request $request,
) {
}
@ -35,6 +35,33 @@ class DownloadResponseFactory
);
}
/**
* Create a response that downloads the given file via a stream.
* Has the option to delete the provided file once the stream is closed.
*/
public function streamedFileDirectly(string $filePath, string $fileName, bool $deleteAfter = false): StreamedResponse
{
$fileSize = filesize($filePath);
$stream = fopen($filePath, 'r');
if ($deleteAfter) {
// Delete the given file if it still exists after the app terminates
$callback = function () use ($filePath) {
if (file_exists($filePath)) {
unlink($filePath);
}
};
// We watch both app terminate and php shutdown to cover both normal app termination
// as well as other potential scenarios (connection termination).
app()->terminating($callback);
register_shutdown_function($callback);
}
return $this->streamedDirectly($stream, $fileName, $fileSize);
}
/**
* Create a file download response that provides the file with a content-type
* correct for the file, in a way so the browser can show the content in browser,
@ -43,7 +70,7 @@ class DownloadResponseFactory
public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse
{
$rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request);
$mime = $rangeStream->sniffMime();
$mime = $rangeStream->sniffMime(pathinfo($fileName, PATHINFO_EXTENSION));
$headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders());
return response()->stream(
@ -53,6 +80,22 @@ class DownloadResponseFactory
);
}
/**
* Create a response that provides the given file via a stream with detected content-type.
* Has the option to delete the provided file once the stream is closed.
*/
public function streamedFileInline(string $filePath, ?string $fileName = null): StreamedResponse
{
$fileSize = filesize($filePath);
$stream = fopen($filePath, 'r');
if ($fileName === null) {
$fileName = basename($filePath);
}
return $this->streamedInline($stream, $fileName, $fileSize);
}
/**
* Get the common headers to provide for a download response.
*/

View File

@ -7,6 +7,13 @@ use Symfony\Component\HttpFoundation\Response;
class PreventResponseCaching
{
/**
* Paths to ignore when preventing response caching.
*/
protected array $ignoredPathPrefixes = [
'theme/',
];
/**
* Handle an incoming request.
*
@ -20,6 +27,13 @@ class PreventResponseCaching
/** @var Response $response */
$response = $next($request);
$path = $request->path();
foreach ($this->ignoredPathPrefixes as $ignoredPath) {
if (str_starts_with($path, $ignoredPath)) {
return $response;
}
}
$response->headers->set('Cache-Control', 'no-cache, no-store, private');
$response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');

View File

@ -32,12 +32,12 @@ class RangeSupportedStream
/**
* Sniff a mime type from the stream.
*/
public function sniffMime(): string
public function sniffMime(string $extension = ''): string
{
$offset = min(2000, $this->fileSize);
$this->sniffContent = fread($this->stream, $offset);
return (new WebSafeMimeSniffer())->sniff($this->sniffContent);
return (new WebSafeMimeSniffer())->sniff($this->sniffContent, $extension);
}
/**
@ -92,7 +92,7 @@ class RangeSupportedStream
if ($start < 0 || $start > $end) {
$this->responseStatus = 416;
$this->responseHeaders['Content-Range'] = sprintf('bytes */%s', $this->fileSize);
} elseif ($end - $start < $this->fileSize - 1) {
} else {
$this->responseLength = $end < $this->fileSize ? $end - $start + 1 : -1;
$this->responseOffset = $start;
$this->responseStatus = 206;

View File

@ -0,0 +1,22 @@
<?php
namespace BookStack\References\ModelResolvers;
use BookStack\Uploads\Attachment;
class AttachmentModelResolver implements CrossLinkModelResolver
{
public function resolve(string $link): ?Attachment
{
$pattern = '/^' . preg_quote(url('/attachments'), '/') . '\/(\d+)/';
$matches = [];
$match = preg_match($pattern, $link, $matches);
if (!$match) {
return null;
}
$id = intval($matches[1]);
return Attachment::query()->find($id);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace BookStack\References\ModelResolvers;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageStorage;
class ImageModelResolver implements CrossLinkModelResolver
{
protected ?string $pattern = null;
public function resolve(string $link): ?Image
{
$pattern = $this->getUrlPattern();
$matches = [];
$match = preg_match($pattern, $link, $matches);
if (!$match) {
return null;
}
$path = $matches[2];
// Strip thumbnail element from path if existing
$originalPathSplit = array_filter(explode('/', $path), function (string $part) {
$resizedDir = (str_starts_with($part, 'thumbs-') || str_starts_with($part, 'scaled-'));
$missingExtension = !str_contains($part, '.');
return !($resizedDir && $missingExtension);
});
// Build a database-format image path and search for the image entry
$fullPath = '/uploads/images/' . ltrim(implode('/', $originalPathSplit), '/');
return Image::query()->where('path', '=', $fullPath)->first();
}
/**
* Get the regex pattern to identify image URLs.
* Caches the pattern since it requires looking up to settings/config.
*/
protected function getUrlPattern(): string
{
if ($this->pattern) {
return $this->pattern;
}
$urls = [url('/uploads/images')];
$baseImageUrl = ImageStorage::getPublicUrl('/uploads/images');
if ($baseImageUrl !== $urls[0]) {
$urls[] = $baseImageUrl;
}
$imageUrlRegex = implode('|', array_map(fn ($url) => preg_quote($url, '/'), $urls));
$this->pattern = '/^(' . $imageUrlRegex . ')\/(.+)/';
return $this->pattern;
}
}

View File

@ -9,21 +9,18 @@ use Illuminate\Http\Request;
class SearchApiController extends ApiController
{
protected SearchRunner $searchRunner;
protected SearchResultsFormatter $resultsFormatter;
protected $rules = [
'all' => [
'query' => ['required'],
'page' => ['integer', 'min:1'],
'count' => ['integer', 'min:1', 'max:100'],
'query' => ['required'],
'page' => ['integer', 'min:1'],
'count' => ['integer', 'min:1', 'max:100'],
],
];
public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter)
{
$this->searchRunner = $searchRunner;
$this->resultsFormatter = $resultsFormatter;
public function __construct(
protected SearchRunner $searchRunner,
protected SearchResultsFormatter $resultsFormatter
) {
}
/**
@ -50,16 +47,16 @@ class SearchApiController extends ApiController
$this->resultsFormatter->format($results['results']->all(), $options);
$data = (new ApiEntityListFormatter($results['results']->all()))
->withType()->withTags()
->withType()->withTags()->withParents()
->withField('preview_html', function (Entity $entity) {
return [
'name' => (string) $entity->getAttribute('preview_name'),
'name' => (string) $entity->getAttribute('preview_name'),
'content' => (string) $entity->getAttribute('preview_content'),
];
})->format();
return response()->json([
'data' => $data,
'data' => $data,
'total' => $results['total'],
]);
}

View File

@ -16,7 +16,13 @@ class SearchIndex
/**
* A list of delimiter characters used to break-up parsed content into terms for indexing.
*/
public static string $delimiters = " \n\t.,!?:;()[]{}<>`'\"";
public static string $delimiters = " \n\t.-,!?:;()[]{}<>`'\"«»";
/**
* A list of delimiter which could be commonly used within a single term and also indicate a break between terms.
* The indexer will index the full term with these delimiters, plus the terms split via these delimiters.
*/
public static string $softDelimiters = ".-";
public function __construct(
protected EntityProvider $entityProvider
@ -30,7 +36,7 @@ class SearchIndex
{
$this->deleteEntityTerms($entity);
$terms = $this->entityToTermDataArray($entity);
SearchTerm::query()->insert($terms);
$this->insertTerms($terms);
}
/**
@ -46,10 +52,7 @@ class SearchIndex
array_push($terms, ...$entityTerms);
}
$chunkedTerms = array_chunk($terms, 500);
foreach ($chunkedTerms as $termChunk) {
SearchTerm::query()->insert($termChunk);
}
$this->insertTerms($terms);
}
/**
@ -99,6 +102,19 @@ class SearchIndex
$entity->searchTerms()->delete();
}
/**
* Insert the given terms into the database.
* Chunks through the given terms to remain within database limits.
* @param array[] $terms
*/
protected function insertTerms(array $terms): void
{
$chunkedTerms = array_chunk($terms, 500);
foreach ($chunkedTerms as $termChunk) {
SearchTerm::query()->insert($termChunk);
}
}
/**
* Create a scored term array from the given text, where the keys are the terms
* and the values are their scores.
@ -186,15 +202,36 @@ class SearchIndex
protected function textToTermCountMap(string $text): array
{
$tokenMap = []; // {TextToken => OccurrenceCount}
$splitChars = static::$delimiters;
$token = strtok($text, $splitChars);
$softDelims = static::$softDelimiters;
$tokenizer = new SearchTextTokenizer($text, static::$delimiters);
$extendedToken = '';
$extendedLen = 0;
$token = $tokenizer->next();
while ($token !== false) {
if (!isset($tokenMap[$token])) {
$tokenMap[$token] = 0;
$delim = $tokenizer->previousDelimiter();
if ($delim && str_contains($softDelims, $delim) && $token !== '') {
$extendedToken .= $delim . $token;
$extendedLen++;
} else {
if ($extendedLen > 1) {
$tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
}
$extendedToken = $token;
$extendedLen = 1;
}
$tokenMap[$token]++;
$token = strtok($splitChars);
if ($token) {
$tokenMap[$token] = ($tokenMap[$token] ?? 0) + 1;
}
$token = $tokenizer->next();
}
if ($extendedLen > 1) {
$tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
}
return $tokenMap;

View File

@ -181,7 +181,7 @@ class SearchOptions
protected static function parseStandardTermString(string $termString): array
{
$terms = explode(' ', $termString);
$indexDelimiters = SearchIndex::$delimiters;
$indexDelimiters = implode('', array_diff(str_split(SearchIndex::$delimiters), str_split(SearchIndex::$softDelimiters)));
$parsed = [
'terms' => [],
'exacts' => [],

View File

@ -0,0 +1,70 @@
<?php
namespace BookStack\Search;
/**
* A custom text tokenizer which records & provides insight needed for our search indexing.
* We used to use basic strtok() but this class does the following which that lacked:
* - Tracks and provides the current/previous delimiter that we've stopped at.
* - Returns empty tokens upon parsing a delimiter.
*/
class SearchTextTokenizer
{
protected int $currentIndex = 0;
protected int $length;
protected string $currentDelimiter = '';
protected string $previousDelimiter = '';
public function __construct(
protected string $text,
protected string $delimiters = ' '
) {
$this->length = strlen($this->text);
}
/**
* Get the current delimiter to be found.
*/
public function currentDelimiter(): string
{
return $this->currentDelimiter;
}
/**
* Get the previous delimiter found.
*/
public function previousDelimiter(): string
{
return $this->previousDelimiter;
}
/**
* Get the next token between delimiters.
* Returns false if there's no further tokens.
*/
public function next(): string|false
{
$token = '';
for ($i = $this->currentIndex; $i < $this->length; $i++) {
$char = $this->text[$i];
if (str_contains($this->delimiters, $char)) {
$this->previousDelimiter = $this->currentDelimiter;
$this->currentDelimiter = $char;
$this->currentIndex = $i + 1;
return $token;
}
$token .= $char;
}
if ($token) {
$this->currentIndex = $this->length;
$this->previousDelimiter = $this->currentDelimiter;
$this->currentDelimiter = '';
return $token;
}
return false;
}
}

View File

@ -9,8 +9,6 @@ use Illuminate\Http\Request;
class SettingController extends Controller
{
protected array $settingCategories = ['features', 'customization', 'registration'];
/**
* Handle requests to the settings index path.
*/
@ -31,7 +29,7 @@ class SettingController extends Controller
// Get application version
$version = trim(file_get_contents(base_path('version')));
return view('settings.' . $category, [
return view('settings.categories.' . $category, [
'category' => $category,
'version' => $version,
'guestUser' => User::getGuest(),
@ -59,7 +57,7 @@ class SettingController extends Controller
protected function ensureCategoryExists(string $category): void
{
if (!in_array($category, $this->settingCategories)) {
if (!view()->exists('settings.categories.' . $category)) {
abort(404);
}
}

View File

@ -1,11 +1,10 @@
<?php
namespace BookStack\Entities\Controllers;
namespace BookStack\Sorting;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\BookSortMap;
use BookStack\Facades\Activity;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
@ -45,25 +44,40 @@ class BookSortController extends Controller
}
/**
* Sorts a book using a given mapping array.
* Update the sort options of a book, setting the auto-sort and/or updating
* child order via mapping.
*/
public function update(Request $request, string $bookSlug)
public function update(Request $request, BookSorter $sorter, string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$loggedActivityForBook = false;
// Return if no map sent
if (!$request->filled('sort-tree')) {
return redirect($book->getUrl());
// Sort via map
if ($request->filled('sort-tree')) {
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
$booksInvolved = $sorter->sortUsingMap($sortMap);
// Rebuild permissions and add activity for involved books.
foreach ($booksInvolved as $bookInvolved) {
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
if ($bookInvolved->id === $book->id) {
$loggedActivityForBook = true;
}
}
}
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
$bookContents = new BookContents($book);
$booksInvolved = $bookContents->sortUsingMap($sortMap);
// Rebuild permissions and add activity for involved books.
foreach ($booksInvolved as $bookInvolved) {
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
if ($request->filled('auto-sort')) {
$sortSetId = intval($request->get('auto-sort')) ?: null;
if ($sortSetId && SortRule::query()->find($sortSetId) === null) {
$sortSetId = null;
}
$book->sort_rule_id = $sortSetId;
$book->save();
$sorter->runBookAutoSort($book);
if (!$loggedActivityForBook) {
Activity::add(ActivityType::BOOK_SORT, $book);
}
}
return redirect($book->getUrl());

View File

@ -1,6 +1,6 @@
<?php
namespace BookStack\Entities\Tools;
namespace BookStack\Sorting;
class BookSortMap
{

View File

@ -1,6 +1,6 @@
<?php
namespace BookStack\Entities\Tools;
namespace BookStack\Sorting;
class BookSortMapItem
{

284
app/Sorting/BookSorter.php Normal file
View File

@ -0,0 +1,284 @@
<?php
namespace BookStack\Sorting;
use BookStack\App\Model;
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;
class BookSorter
{
public function __construct(
protected EntityQueries $queries,
) {
}
public function runBookAutoSortForAllWithSet(SortRule $set): void
{
$set->books()->chunk(50, function ($books) {
foreach ($books as $book) {
$this->runBookAutoSort($book);
}
});
}
/**
* Runs the auto-sort for a book if the book has a sort set applied to it.
* This does not consider permissions since the sort operations are centrally
* managed by admins so considered permitted if existing and assigned.
*/
public function runBookAutoSort(Book $book): void
{
$set = $book->sortRule;
if (!$set) {
return;
}
$sortFunctions = array_map(function (SortRuleOperation $op) {
return $op->getSortFunction();
}, $set->getOperations());
$chapters = $book->chapters()
->with('pages:id,name,priority,created_at,updated_at,chapter_id')
->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
/** @var (Chapter|Book)[] $topItems */
$topItems = [
...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
...$chapters,
];
foreach ($sortFunctions as $sortFunction) {
usort($topItems, $sortFunction);
}
foreach ($topItems as $index => $topItem) {
$topItem->priority = $index + 1;
$topItem::withoutTimestamps(fn () => $topItem->save());
}
foreach ($chapters as $chapter) {
$pages = $chapter->pages->all();
foreach ($sortFunctions as $sortFunction) {
usort($pages, $sortFunction);
}
foreach ($pages as $index => $page) {
$page->priority = $index + 1;
$page::withoutTimestamps(fn () => $page->save());
}
}
}
/**
* Sort the books content using the given sort map.
* Returns a list of books that were involved in the operation.
*
* @returns Book[]
*/
public function sortUsingMap(BookSortMap $sortMap): array
{
// Load models into map
$modelMap = $this->loadModelsFromSortMap($sortMap);
// Sort our changes from our map to be chapters first
// Since they need to be process to ensure book alignment for child page changes.
$sortMapItems = $sortMap->all();
usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
$aScore = $itemA->type === 'page' ? 2 : 1;
$bScore = $itemB->type === 'page' ? 2 : 1;
return $aScore - $bScore;
});
// Perform the sort
foreach ($sortMapItems as $item) {
$this->applySortUpdates($item, $modelMap);
}
/** @var Book[] $booksInvolved */
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
return str_starts_with($key, 'book:');
}, ARRAY_FILTER_USE_KEY));
// Update permissions of books involved
foreach ($booksInvolved as $book) {
$book->rebuildPermissions();
}
return $booksInvolved;
}
/**
* Using the given sort map item, detect changes for the related model
* and update it if required. Changes where permissions are lacking will
* be skipped and not throw an error.
*
* @param array<string, Entity> $modelMap
*/
protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
{
/** @var BookChild $model */
$model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
if (!$model) {
return;
}
$priorityChanged = $model->priority !== $sortMapItem->sort;
$bookChanged = $model->book_id !== $sortMapItem->parentBookId;
$chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
// Stop if there's no change
if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
return;
}
$currentParentKey = 'book:' . $model->book_id;
if ($model instanceof Page && $model->chapter_id) {
$currentParentKey = 'chapter:' . $model->chapter_id;
}
$currentParent = $modelMap[$currentParentKey] ?? null;
/** @var Book $newBook */
$newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
/** @var ?Chapter $newChapter */
$newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
return;
}
// Action the required changes
if ($bookChanged) {
$model->changeBook($newBook->id);
}
if ($model instanceof Page && $chapterChanged) {
$model->chapter_id = $newChapter->id ?? 0;
}
if ($priorityChanged) {
$model->priority = $sortMapItem->sort;
}
if ($chapterChanged || $priorityChanged) {
$model::withoutTimestamps(fn () => $model->save());
}
}
/**
* Check if the current user has permissions to apply the given sorting change.
* Is quite complex since items can gain a different parent change. Acts as a:
* - Update of old parent element (Change of content/order).
* - Update of sorted/moved element.
* - Deletion of element (Relative to parent upon move).
* - Creation of element within parent (Upon move to new parent).
*/
protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
{
// Stop if we can't see the current parent or new book.
if (!$currentParent || !$newBook) {
return false;
}
$hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
if ($model instanceof Chapter) {
$hasPermission = userCan('book-update', $currentParent)
&& userCan('book-update', $newBook)
&& userCan('chapter-update', $model)
&& (!$hasNewParent || userCan('chapter-create', $newBook))
&& (!$hasNewParent || userCan('chapter-delete', $model));
if (!$hasPermission) {
return false;
}
}
if ($model instanceof Page) {
$parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasCurrentParentPermission = userCan($parentPermission, $currentParent);
// This needs to check if there was an intended chapter location in the original sort map
// rather than inferring from the $newChapter since that variable may be null
// due to other reasons (Visibility).
$newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
if (!$newParent) {
return false;
}
$hasPageEditPermission = userCan('page-update', $model);
$newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasNewParentPermission = userCan($newParentPermission, $newParent);
$hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
$hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
$hasPermission = $hasCurrentParentPermission
&& $newParentInRightLocation
&& $hasNewParentPermission
&& $hasPageEditPermission
&& $hasDeletePermissionIfMoving
&& $hasCreatePermissionIfMoving;
if (!$hasPermission) {
return false;
}
}
return true;
}
/**
* Load models from the database into the given sort map.
*
* @return array<string, Entity>
*/
protected function loadModelsFromSortMap(BookSortMap $sortMap): array
{
$modelMap = [];
$ids = [
'chapter' => [],
'page' => [],
'book' => [],
];
foreach ($sortMap->all() as $sortMapItem) {
$ids[$sortMapItem->type][] = $sortMapItem->id;
$ids['book'][] = $sortMapItem->parentBookId;
if ($sortMapItem->parentChapterId) {
$ids['chapter'][] = $sortMapItem->parentChapterId;
}
}
$pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
/** @var Page $page */
foreach ($pages as $page) {
$modelMap['page:' . $page->id] = $page;
$ids['book'][] = $page->book_id;
if ($page->chapter_id) {
$ids['chapter'][] = $page->chapter_id;
}
}
$chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
/** @var Chapter $chapter */
foreach ($chapters as $chapter) {
$modelMap['chapter:' . $chapter->id] = $chapter;
$ids['book'][] = $chapter->book_id;
}
$books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
/** @var Book $book */
foreach ($books as $book) {
$modelMap['book:' . $book->id] = $book;
}
return $modelMap;
}
}

63
app/Sorting/SortRule.php Normal file
View File

@ -0,0 +1,63 @@
<?php
namespace BookStack\Sorting;
use BookStack\Activity\Models\Loggable;
use BookStack\Entities\Models\Book;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property string $name
* @property string $sequence
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class SortRule extends Model implements Loggable
{
use HasFactory;
/**
* @return SortRuleOperation[]
*/
public function getOperations(): array
{
return SortRuleOperation::fromSequence($this->sequence);
}
/**
* @param SortRuleOperation[] $options
*/
public function setOperations(array $options): void
{
$values = array_map(fn (SortRuleOperation $opt) => $opt->value, $options);
$this->sequence = implode(',', $values);
}
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}";
}
public function getUrl(): string
{
return url("/settings/sorting/rules/{$this->id}");
}
public function books(): HasMany
{
return $this->hasMany(Book::class);
}
public static function allByName(): Collection
{
return static::query()
->withCount('books')
->orderBy('name', 'asc')
->get();
}
}

View File

@ -0,0 +1,114 @@
<?php
namespace BookStack\Sorting;
use BookStack\Activity\ActivityType;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
class SortRuleController extends Controller
{
public function __construct()
{
$this->middleware('can:settings-manage');
}
public function create()
{
$this->setPageTitle(trans('settings.sort_rule_create'));
return view('settings.sort-rules.create');
}
public function store(Request $request)
{
$this->validate($request, [
'name' => ['required', 'string', 'min:1', 'max:200'],
'sequence' => ['required', 'string', 'min:1'],
]);
$operations = SortRuleOperation::fromSequence($request->input('sequence'));
if (count($operations) === 0) {
return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']);
}
$rule = new SortRule();
$rule->name = $request->input('name');
$rule->setOperations($operations);
$rule->save();
$this->logActivity(ActivityType::SORT_RULE_CREATE, $rule);
return redirect('/settings/sorting');
}
public function edit(string $id)
{
$rule = SortRule::query()->findOrFail($id);
$this->setPageTitle(trans('settings.sort_rule_edit'));
return view('settings.sort-rules.edit', ['rule' => $rule]);
}
public function update(string $id, Request $request, BookSorter $bookSorter)
{
$this->validate($request, [
'name' => ['required', 'string', 'min:1', 'max:200'],
'sequence' => ['required', 'string', 'min:1'],
]);
$rule = SortRule::query()->findOrFail($id);
$operations = SortRuleOperation::fromSequence($request->input('sequence'));
if (count($operations) === 0) {
return redirect($rule->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']);
}
$rule->name = $request->input('name');
$rule->setOperations($operations);
$changedSequence = $rule->isDirty('sequence');
$rule->save();
$this->logActivity(ActivityType::SORT_RULE_UPDATE, $rule);
if ($changedSequence) {
$bookSorter->runBookAutoSortForAllWithSet($rule);
}
return redirect('/settings/sorting');
}
public function destroy(string $id, Request $request)
{
$rule = SortRule::query()->findOrFail($id);
$confirmed = $request->input('confirm') === 'true';
$booksAssigned = $rule->books()->count();
$warnings = [];
if ($booksAssigned > 0) {
if ($confirmed) {
$rule->books()->update(['sort_rule_id' => null]);
} else {
$warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]);
}
}
$defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
if ($defaultBookSortSetting === intval($id)) {
if ($confirmed) {
setting()->remove('sorting-book-default');
} else {
$warnings[] = trans('settings.sort_rule_delete_warn_default');
}
}
if (count($warnings) > 0) {
return redirect($rule->getUrl() . '#delete')->withErrors(['delete' => $warnings]);
}
$rule->delete();
$this->logActivity(ActivityType::SORT_RULE_DELETE, $rule);
return redirect('/settings/sorting');
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace BookStack\Sorting;
use Closure;
use Illuminate\Support\Str;
enum SortRuleOperation: string
{
case NameAsc = 'name_asc';
case NameDesc = 'name_desc';
case NameNumericAsc = 'name_numeric_asc';
case NameNumericDesc = 'name_numeric_desc';
case CreatedDateAsc = 'created_date_asc';
case CreatedDateDesc = 'created_date_desc';
case UpdateDateAsc = 'updated_date_asc';
case UpdateDateDesc = 'updated_date_desc';
case ChaptersFirst = 'chapters_first';
case ChaptersLast = 'chapters_last';
/**
* Provide a translated label string for this option.
*/
public function getLabel(): string
{
$key = $this->value;
$label = '';
if (str_ends_with($key, '_asc')) {
$key = substr($key, 0, -4);
$label = trans('settings.sort_rule_op_asc');
} elseif (str_ends_with($key, '_desc')) {
$key = substr($key, 0, -5);
$label = trans('settings.sort_rule_op_desc');
}
$label = trans('settings.sort_rule_op_' . $key) . ' ' . $label;
return trim($label);
}
public function getSortFunction(): callable
{
$camelValue = Str::camel($this->value);
return SortSetOperationComparisons::$camelValue(...);
}
/**
* @return SortRuleOperation[]
*/
public static function allExcluding(array $operations): array
{
$all = SortRuleOperation::cases();
$filtered = array_filter($all, function (SortRuleOperation $operation) use ($operations) {
return !in_array($operation, $operations);
});
return array_values($filtered);
}
/**
* Create a set of operations from a string sequence representation.
* (values seperated by commas).
* @return SortRuleOperation[]
*/
public static function fromSequence(string $sequence): array
{
$strOptions = explode(',', $sequence);
$options = array_map(fn ($val) => SortRuleOperation::tryFrom($val), $strOptions);
return array_filter($options);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace BookStack\Sorting;
use voku\helper\ASCII;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
/**
* Sort comparison function for each of the possible SortSetOperation values.
* Method names should be camelCase names for the SortSetOperation enum value.
*/
class SortSetOperationComparisons
{
public static function nameAsc(Entity $a, Entity $b): int
{
return strtolower(ASCII::to_transliterate($a->name, null)) <=> strtolower(ASCII::to_transliterate($b->name, null));
}
public static function nameDesc(Entity $a, Entity $b): int
{
return strtolower(ASCII::to_transliterate($b->name, null)) <=> strtolower(ASCII::to_transliterate($a->name, null));
}
public static function nameNumericAsc(Entity $a, Entity $b): int
{
$numRegex = '/^\d+(\.\d+)?/';
$aMatches = [];
$bMatches = [];
preg_match($numRegex, $a->name, $aMatches);
preg_match($numRegex, $b->name, $bMatches);
$aVal = floatval(($aMatches[0] ?? 0));
$bVal = floatval(($bMatches[0] ?? 0));
return $aVal <=> $bVal;
}
public static function nameNumericDesc(Entity $a, Entity $b): int
{
return -(static::nameNumericAsc($a, $b));
}
public static function createdDateAsc(Entity $a, Entity $b): int
{
return $a->created_at->unix() <=> $b->created_at->unix();
}
public static function createdDateDesc(Entity $a, Entity $b): int
{
return $b->created_at->unix() <=> $a->created_at->unix();
}
public static function updatedDateAsc(Entity $a, Entity $b): int
{
return $a->updated_at->unix() <=> $b->updated_at->unix();
}
public static function updatedDateDesc(Entity $a, Entity $b): int
{
return $b->updated_at->unix() <=> $a->updated_at->unix();
}
public static function chaptersFirst(Entity $a, Entity $b): int
{
return ($b instanceof Chapter ? 1 : 0) - (($a instanceof Chapter) ? 1 : 0);
}
public static function chaptersLast(Entity $a, Entity $b): int
{
return ($a instanceof Chapter ? 1 : 0) - (($b instanceof Chapter) ? 1 : 0);
}
}

49
app/Sorting/SortUrl.php Normal file
View File

@ -0,0 +1,49 @@
<?php
namespace BookStack\Sorting;
/**
* Generate a URL with multiple parameters for sorting purposes.
* Works out the logic to set the correct sorting direction
* Discards empty parameters and allows overriding.
*/
class SortUrl
{
public function __construct(
protected string $path,
protected array $data,
protected array $overrideData = []
) {
}
public function withOverrideData(array $overrideData = []): self
{
return new self($this->path, $this->data, $overrideData);
}
public function build(): string
{
$queryStringSections = [];
$queryData = array_merge($this->data, $this->overrideData);
// Change sorting direction if already sorted on current attribute
if (isset($this->overrideData['sort']) && $this->overrideData['sort'] === $this->data['sort']) {
$queryData['order'] = ($this->data['order'] === 'asc') ? 'desc' : 'asc';
} elseif (isset($this->overrideData['sort'])) {
$queryData['order'] = 'asc';
}
foreach ($queryData as $name => $value) {
$trimmedVal = trim($value);
if ($trimmedVal !== '') {
$queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal);
}
}
if (count($queryStringSections) === 0) {
return url($this->path);
}
return url($this->path . '?' . implode('&', $queryStringSections));
}
}

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