Compare commits

...

131 Commits

Author SHA1 Message Date
Dan Brown
6d2cd20e80 Updated version and assets for release v24.12 2024-12-23 11:55:23 +00:00
Dan Brown
b0c574356a Merge branch 'development' into release 2024-12-23 11:55:02 +00:00
Dan Brown
980a684b14 Updated translator & dependency attribution before release v24.12 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
Added test to cover
2024-12-22 12:43:26 +00:00
Dan Brown
01825ddb93 Dependancies: Bumped up composer dep versions 2024-12-21 15:48:46 +00:00
Dan Brown
1f88bc2a59 Merge pull request #5365 from BookStackApp/lexical_fixes
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
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
- 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
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
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 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
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
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
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
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
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
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 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 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 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 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
07e45a20e5 Updated version and assets for release v24.10.3 2024-11-29 13:50:41 +00:00
Dan Brown
14056c69e6 Updated version and assets for release v24.10.2 2024-11-29 13:47:24 +00:00
Dan Brown
fb9c840c46 Merge branch 'development' into release 2024-11-29 13:47:08 +00:00
Dan Brown
94165cc18f Updated translator & dependency attribution before release v24.10.2 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 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
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 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
- 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
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
5fba4a5399 Updated version and assets for release v24.10.2 2024-11-13 12:03:15 +00:00
Dan Brown
c0b377050e Merge branch 'development' into release 2024-11-13 12:02:30 +00:00
Dan Brown
306b8774c2 Updated translations with latest Crowdin changes (#5317)
* 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
f3efb6441d Updated version and assets for release v24.10.1 2024-11-08 13:53:06 +00:00
Dan Brown
0cf313a21e Merge branch 'development' into release 2024-11-08 13:52:37 +00:00
Dan Brown
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 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 2024-11-04 16:21:22 +00:00
Dan Brown
8f6f81948e ZIP Imports: Fleshed out continue page, Added testing 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
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 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 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
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 2024-10-27 14:33:43 +00:00
Dan Brown
e4ca3bf132 Merge pull request #5291 from LordSimal/development
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 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 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 2024-10-20 19:56:56 +01:00
Dan Brown
21ccfa97dd ZIP Export: Expanded page & added base attachment handling 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
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
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 2024-10-11 21:55:51 +01:00
Dan Brown
f41c02cbd7 TS: Converted app file and animations service
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 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
671 changed files with 19831 additions and 7084 deletions

View File

@@ -449,3 +449,15 @@ 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

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.1', '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

@@ -16,9 +16,9 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['8.1', '8.2', '8.3']
php: ['8.1', '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

@@ -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,11 +101,11 @@ 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)) {
@@ -95,7 +115,7 @@ class LdapService
$userCn = $this->getUserResponseProperty($user, 'cn', null);
$formatted = [
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'name' => $this->getUserDisplayName($user, $displayNameAttrs, $userCn),
'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

@@ -67,6 +67,10 @@ 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';
/**
* Get all the possible values.
*/

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

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

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

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

View File

@@ -87,6 +87,17 @@ class PageRepo
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.
*/
@@ -121,7 +132,7 @@ class PageRepo
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');

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;
@@ -63,4 +65,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()->streamedDirectly(fopen($zip, 'r'), $bookSlug . '.zip', filesize($zip));
}
}

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;
@@ -70,4 +71,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()->streamedDirectly(fopen($zip, 'r'), $chapterSlug . '.zip', filesize($zip));
}
}

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;
@@ -74,4 +75,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()->streamedDirectly(fopen($zip, 'r'), $pageSlug . '.zip', filesize($zip));
}
}

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;

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,100 @@
<?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 = [];
$this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove) {
$zip->addFile($filePath, "files/$fileRef");
$toRemove[] = $filePath;
});
$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

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

@@ -30,7 +30,7 @@ class SearchIndex
{
$this->deleteEntityTerms($entity);
$terms = $this->entityToTermDataArray($entity);
SearchTerm::query()->insert($terms);
$this->insertTerms($terms);
}
/**
@@ -46,10 +46,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 +96,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.

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

@@ -21,6 +21,7 @@ class LocaleManager
protected array $localeMap = [
'ar' => 'ar',
'bg' => 'bg_BG',
'bn' => 'bn_BD',
'bs' => 'bs_BA',
'ca' => 'ca',
'cs' => 'cs_CZ',
@@ -41,6 +42,7 @@ class LocaleManager
'hr' => 'hr_HR',
'hu' => 'hu_HU',
'id' => 'id_ID',
'is' => 'is_IS',
'it' => 'it_IT',
'ja' => 'ja',
'ka' => 'ka_GE',
@@ -60,6 +62,7 @@ class LocaleManager
'sq' => 'sq_AL',
'sr' => 'sr_RS',
'sv' => 'sv_SE',
'tk' => 'tk_TM',
'tr' => 'tr_TR',
'uk' => 'uk_UA',
'uz' => 'uz_UZ',

View File

@@ -4,62 +4,13 @@ namespace BookStack\Uploads;
use BookStack\Exceptions\FileUploadException;
use Exception;
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use League\Flysystem\WhitespacePathNormalizer;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class AttachmentService
{
protected FilesystemManager $fileSystem;
/**
* AttachmentService constructor.
*/
public function __construct(FilesystemManager $fileSystem)
{
$this->fileSystem = $fileSystem;
}
/**
* Get the storage that will be used for storing files.
*/
protected function getStorageDisk(): Storage
{
return $this->fileSystem->disk($this->getStorageDiskName());
}
/**
* Get the name of the storage disk to use.
*/
protected function getStorageDiskName(): string
{
$storageType = config('filesystems.attachments');
// Change to our secure-attachment disk if any of the local options
// are used to prevent escaping that location.
if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
$storageType = 'local_secure_attachments';
}
return $storageType;
}
/**
* Change the originally provided path to fit any disk-specific requirements.
* This also ensures the path is kept to the expected root folders.
*/
protected function adjustPathForStorageDisk(string $path): string
{
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
if ($this->getStorageDiskName() === 'local_secure_attachments') {
return $path;
}
return 'uploads/files/' . $path;
public function __construct(
protected FileStorage $storage,
) {
}
/**
@@ -69,7 +20,7 @@ class AttachmentService
*/
public function streamAttachmentFromStorage(Attachment $attachment)
{
return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path));
return $this->storage->getReadStream($attachment->path);
}
/**
@@ -77,7 +28,7 @@ class AttachmentService
*/
public function getAttachmentFileSize(Attachment $attachment): int
{
return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($attachment->path));
return $this->storage->getSize($attachment->path);
}
/**
@@ -165,16 +116,18 @@ class AttachmentService
*/
public function updateFile(Attachment $attachment, array $requestData): Attachment
{
$attachment->name = $requestData['name'];
$link = trim($requestData['link'] ?? '');
if (isset($requestData['name'])) {
$attachment->name = $requestData['name'];
}
$link = trim($requestData['link'] ?? '');
if (!empty($link)) {
if (!$attachment->external) {
$this->deleteFileInStorage($attachment);
$attachment->external = true;
$attachment->extension = '';
}
$attachment->path = $requestData['link'];
$attachment->path = $link;
}
$attachment->save();
@@ -200,15 +153,9 @@ class AttachmentService
* Delete a file from the filesystem it sits on.
* Cleans any empty leftover folders.
*/
protected function deleteFileInStorage(Attachment $attachment)
public function deleteFileInStorage(Attachment $attachment): void
{
$storage = $this->getStorageDisk();
$dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path));
$storage->delete($this->adjustPathForStorageDisk($attachment->path));
if (count($storage->allFiles($dirPath)) === 0) {
$storage->deleteDirectory($dirPath);
}
$this->storage->delete($attachment->path);
}
/**
@@ -218,32 +165,20 @@ class AttachmentService
*/
protected function putFileInStorage(UploadedFile $uploadedFile): string
{
$storage = $this->getStorageDisk();
$basePath = 'uploads/files/' . date('Y-m-M') . '/';
$uploadFileName = Str::random(16) . '-' . $uploadedFile->getClientOriginalExtension();
while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
$uploadFileName = Str::random(3) . $uploadFileName;
}
$attachmentStream = fopen($uploadedFile->getRealPath(), 'r');
$attachmentPath = $basePath . $uploadFileName;
try {
$storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream);
} catch (Exception $e) {
Log::error('Error when attempting file upload:' . $e->getMessage());
throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentPath]));
}
return $attachmentPath;
return $this->storage->uploadFile(
$uploadedFile,
$basePath,
$uploadedFile->getClientOriginalExtension(),
''
);
}
/**
* Get the file validation rules for attachments.
*/
public function getFileValidationRules(): array
public static function getFileValidationRules(): array
{
return ['file', 'max:' . (config('app.upload_limit') * 1000)];
}

132
app/Uploads/FileStorage.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
namespace BookStack\Uploads;
use BookStack\Exceptions\FileUploadException;
use Exception;
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use League\Flysystem\WhitespacePathNormalizer;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class FileStorage
{
public function __construct(
protected FilesystemManager $fileSystem,
) {
}
/**
* @return resource|null
*/
public function getReadStream(string $path)
{
return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($path));
}
public function getSize(string $path): int
{
return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($path));
}
public function delete(string $path, bool $removeEmptyDir = false): void
{
$storage = $this->getStorageDisk();
$adjustedPath = $this->adjustPathForStorageDisk($path);
$dir = dirname($adjustedPath);
$storage->delete($adjustedPath);
if ($removeEmptyDir && count($storage->allFiles($dir)) === 0) {
$storage->deleteDirectory($dir);
}
}
/**
* @throws FileUploadException
*/
public function uploadFile(UploadedFile $file, string $subDirectory, string $suffix, string $extension): string
{
$storage = $this->getStorageDisk();
$basePath = trim($subDirectory, '/') . '/';
$uploadFileName = Str::random(16) . ($suffix ? "-{$suffix}" : '') . ($extension ? ".{$extension}" : '');
while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
$uploadFileName = Str::random(3) . $uploadFileName;
}
$fileStream = fopen($file->getRealPath(), 'r');
$filePath = $basePath . $uploadFileName;
try {
$storage->writeStream($this->adjustPathForStorageDisk($filePath), $fileStream);
} catch (Exception $e) {
Log::error('Error when attempting file upload:' . $e->getMessage());
throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $filePath]));
}
return $filePath;
}
/**
* Check whether the configured storage is remote from the host of this app.
*/
public function isRemote(): bool
{
return $this->getStorageDiskName() === 's3';
}
/**
* Get the actual path on system for the given relative file path.
*/
public function getSystemPath(string $filePath): string
{
if ($this->isRemote()) {
return '';
}
return storage_path('uploads/files/' . ltrim($this->adjustPathForStorageDisk($filePath), '/'));
}
/**
* Get the storage that will be used for storing files.
*/
protected function getStorageDisk(): Storage
{
return $this->fileSystem->disk($this->getStorageDiskName());
}
/**
* Get the name of the storage disk to use.
*/
protected function getStorageDiskName(): string
{
$storageType = trim(strtolower(config('filesystems.attachments')));
// Change to our secure-attachment disk if any of the local options
// are used to prevent escaping that location.
if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
$storageType = 'local_secure_attachments';
}
return $storageType;
}
/**
* Change the originally provided path to fit any disk-specific requirements.
* This also ensures the path is kept to the expected root folders.
*/
protected function adjustPathForStorageDisk(string $path): string
{
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
if ($this->getStorageDiskName() === 'local_secure_attachments') {
return $path;
}
return 'uploads/files/' . $path;
}
}

View File

@@ -33,9 +33,10 @@ class ImageService
int $uploadedTo = 0,
int $resizeWidth = null,
int $resizeHeight = null,
bool $keepRatio = true
bool $keepRatio = true,
string $imageName = '',
): Image {
$imageName = $uploadedFile->getClientOriginalName();
$imageName = $imageName ?: $uploadedFile->getClientOriginalName();
$imageData = file_get_contents($uploadedFile->getRealPath());
if ($resizeWidth !== null || $resizeHeight !== null) {
@@ -133,6 +134,19 @@ class ImageService
return $disk->get($image->path);
}
/**
* Get the raw data content from an image.
*
* @throws Exception
* @returns ?resource
*/
public function getImageStream(Image $image): mixed
{
$disk = $this->storage->getDisk();
return $disk->stream($image->path);
}
/**
* Destroy an image along with its revisions, thumbnails and remaining folders.
*
@@ -140,11 +154,19 @@ class ImageService
*/
public function destroy(Image $image): void
{
$disk = $this->storage->getDisk($image->type);
$disk->destroyAllMatchingNameFromPath($image->path);
$this->destroyFileAtPath($image->type, $image->path);
$image->delete();
}
/**
* Destroy the underlying image file at the given path.
*/
public function destroyFileAtPath(string $type, string $path): void
{
$disk = $this->storage->getDisk($type);
$disk->destroyAllMatchingNameFromPath($path);
}
/**
* Delete gallery and drawings that are not within HTML content of pages or page revisions.
* Checks based off of only the image name.

View File

@@ -110,10 +110,20 @@ class ImageStorage
}
/**
* Gets a public facing url for an image by checking relevant environment variables.
* Gets a public facing url for an image or location at the given path.
*/
public static function getPublicUrl(string $filePath): string
{
return static::getPublicBaseUrl() . '/' . ltrim($filePath, '/');
}
/**
* Get the public base URL used for images.
* Will not include any path element of the image file, just the base part
* from where the path is then expected to start from.
* If s3-style store is in use it will default to guessing a public bucket URL.
*/
public function getPublicUrl(string $filePath): string
protected static function getPublicBaseUrl(): string
{
$storageUrl = config('filesystems.url');
@@ -131,6 +141,6 @@ class ImageStorage
$basePath = $storageUrl ?: url('/');
return rtrim($basePath, '/') . $filePath;
return rtrim($basePath, '/');
}
}

View File

@@ -55,6 +55,15 @@ class ImageStorageDisk
return $this->filesystem->get($this->adjustPathForDisk($path));
}
/**
* Get a stream to the file at the given path.
* @returns ?resource
*/
public function stream(string $path): mixed
{
return $this->filesystem->readStream($this->adjustPathForDisk($path));
}
/**
* Save the given image data at the given path. Can choose to set
* the image as public which will update its visibility after saving.

View File

@@ -37,7 +37,7 @@ class UserApiController extends ApiController
{
return [
'create' => [
'name' => ['required', 'string', 'min:2', 'max:100'],
'name' => ['required', 'string', 'min:1', 'max:100'],
'email' => [
'required', 'string', 'email', 'min:2', new Unique('users', 'email'),
],
@@ -49,7 +49,7 @@ class UserApiController extends ApiController
'send_invite' => ['boolean'],
],
'update' => [
'name' => ['string', 'min:2', 'max:100'],
'name' => ['string', 'min:1', 'max:100'],
'email' => [
'string',
'email',

View File

@@ -144,7 +144,7 @@ class UserController extends Controller
$this->checkPermission('users-manage');
$validated = $this->validate($request, [
'name' => ['min:2', 'max:100'],
'name' => ['min:1', 'max:100'],
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
'password' => ['required_with:password_confirm', Password::default()],
'password-confirm' => ['same:password', 'required_with:password'],

View File

@@ -6,6 +6,7 @@ use DOMDocument;
use DOMElement;
use DOMNode;
use DOMNodeList;
use DOMText;
use DOMXPath;
/**
@@ -81,6 +82,14 @@ class HtmlDocument
return $element;
}
/**
* Create a new text node within this document.
*/
public function createTextNode(string $text): DOMText
{
return $this->document->createTextNode($text);
}
/**
* Get an element within the document of the given ID.
*/

Binary file not shown.

View File

@@ -16,13 +16,14 @@
"ext-json": "*",
"ext-mbstring": "*",
"ext-xml": "*",
"ext-zip": "*",
"bacon/bacon-qr-code": "^3.0",
"doctrine/dbal": "^3.5",
"dompdf/dompdf": "^3.0",
"guzzlehttp/guzzle": "^7.4",
"intervention/image": "^3.5",
"knplabs/knp-snappy": "^1.5",
"laravel/framework": "^10.10",
"laravel/framework": "^10.48.23",
"laravel/socialite": "^5.10",
"laravel/tinker": "^2.8",
"league/commonmark": "^2.3",

990
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
<?php
namespace Database\Factories\Exports;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class ImportFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = \BookStack\Exports\Import::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'path' => 'uploads/files/imports/' . Str::random(10) . '.zip',
'name' => $this->faker->words(3, true),
'type' => 'book',
'metadata' => '{"name": "My book"}',
'created_at' => User::factory(),
];
}
}

View File

@@ -11,8 +11,7 @@ return new class extends Migration
*/
public function up(): void
{
// Create new templates-manage permission and assign to admin role
$roles = DB::table('roles')->get('id');
// Create new content-export permission
$permissionId = DB::table('role_permissions')->insertGetId([
'name' => 'content-export',
'display_name' => 'Export Content',
@@ -20,6 +19,7 @@ return new class extends Migration
'updated_at' => Carbon::now()->toDateTimeString(),
]);
$roles = DB::table('roles')->get('id');
$permissionRoles = $roles->map(function ($role) use ($permissionId) {
return [
'role_id' => $role->id,
@@ -27,6 +27,7 @@ return new class extends Migration
];
})->values()->toArray();
// Assign to all existing roles in the system
DB::table('permission_role')->insert($permissionRoles);
}
@@ -40,6 +41,6 @@ return new class extends Migration
->where('name', '=', 'content-export')->first();
DB::table('permission_role')->where('permission_id', '=', $contentExportPermission->id)->delete();
DB::table('role_permissions')->where('id', '=', 'content-export')->delete();
DB::table('role_permissions')->where('id', '=', $contentExportPermission->id)->delete();
}
};

View File

@@ -0,0 +1,61 @@
<?php
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Create new content-import permission
$permissionId = DB::table('role_permissions')->insertGetId([
'name' => 'content-import',
'display_name' => 'Import Content',
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
// Get existing admin-level role ids
$settingManagePermission = DB::table('role_permissions')
->where('name', '=', 'settings-manage')->first();
if (!$settingManagePermission) {
return;
}
$adminRoleIds = DB::table('permission_role')
->where('permission_id', '=', $settingManagePermission->id)
->pluck('role_id')->all();
// Assign the new permission to all existing admins
$newPermissionRoles = array_values(array_map(function ($roleId) use ($permissionId) {
return [
'role_id' => $roleId,
'permission_id' => $permissionId,
];
}, $adminRoleIds));
DB::table('permission_role')->insert($newPermissionRoles);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Remove content-import permission
$importPermission = DB::table('role_permissions')
->where('name', '=', 'content-import')->first();
if (!$importPermission) {
return;
}
DB::table('permission_role')->where('permission_id', '=', $importPermission->id)->delete();
DB::table('role_permissions')->where('id', '=', $importPermission->id)->delete();
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('imports', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('path');
$table->integer('size');
$table->string('type');
$table->longText('metadata');
$table->integer('created_by')->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('imports');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('settings')->insert([
'setting_key' => 'instance-id',
'value' => Str::uuid(),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
'type' => 'string',
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::table('settings')->where('setting_key', '=', 'instance-id')->delete();
}
};

View File

@@ -1 +1 @@
GET /api/search?query=cats+{created_by:me}&page=1&count=2
GET /api/search?query=cats+{created_by:me}&page=1&count=2

View File

@@ -9,7 +9,8 @@
"updated_at": "2019-12-11T20:57:31.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1
"owned_by": 1,
"cover": null
},
{
"id": 2,
@@ -20,7 +21,12 @@
"updated_at": "2019-12-11T20:57:23.000000Z",
"created_by": 4,
"updated_by": 3,
"owned_by": 3
"owned_by": 3,
"cover": {
"id": 11,
"name": "cat_banner.jpg",
"url": "https://example.com/uploads/images/cover_book/2021-10/cat-banner.jpg"
}
}
],
"total": 14

View File

@@ -8,7 +8,12 @@
"created_at": "2021-11-14T15:57:35.000000Z",
"updated_at": "2021-11-14T15:57:35.000000Z",
"type": "chapter",
"url": "https://example.com/books/my-book/chapter/a-chapter-for-cats",
"url": "https://example.com/books/cats/chapter/a-chapter-for-cats",
"book": {
"id": 1,
"name": "Cats",
"slug": "cats"
},
"preview_html": {
"name": "A chapter for <strong>cats</strong>",
"content": "...once a bunch of <strong>cats</strong> named tony...behaviour of <strong>cats</strong> is unsuitable"
@@ -26,7 +31,17 @@
"created_at": "2021-05-15T16:28:10.000000Z",
"updated_at": "2021-11-14T15:56:49.000000Z",
"type": "page",
"url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats",
"url": "https://example.com/books/cats/page/the-hows-and-whys-of-cats",
"book": {
"id": 1,
"name": "Cats",
"slug": "cats"
},
"chapter": {
"id": 75,
"name": "A chapter for cats",
"slug": "a-chapter-for-cats"
},
"preview_html": {
"name": "The hows and whys of <strong>cats</strong>",
"content": "...people ask why <strong>cats</strong>? but there are...the reason that <strong>cats</strong> are fast are due to..."
@@ -55,7 +70,17 @@
"created_at": "2020-11-29T21:55:07.000000Z",
"updated_at": "2021-11-14T16:02:39.000000Z",
"type": "page",
"url": "https://example.com/books/my-book/page/how-advanced-are-cats",
"url": "https://example.com/books/big-cats/page/how-advanced-are-cats",
"book": {
"id": 13,
"name": "Big Cats",
"slug": "big-cats"
},
"chapter": {
"id": 73,
"name": "A chapter for bigger cats",
"slug": "a-chapter-for-bigger-cats"
},
"preview_html": {
"name": "How advanced are <strong>cats</strong>?",
"content": "<strong>cats</strong> are some of the most advanced animals in the world."
@@ -64,4 +89,4 @@
}
],
"total": 3
}
}

View File

@@ -9,7 +9,12 @@
"updated_at": "2020-04-10T13:00:45.000000Z",
"created_by": 4,
"updated_by": 1,
"owned_by": 1
"owned_by": 1,
"cover": {
"id": 4,
"name": "shelf.jpg",
"url": "https://example.com/uploads/images/cover_bookshelf/2024-12/shelf.jpg"
}
},
{
"id": 9,
@@ -20,7 +25,8 @@
"updated_at": "2020-04-10T13:00:58.000000Z",
"created_by": 4,
"updated_by": 1,
"owned_by": 1
"owned_by": 1,
"cover": null
},
{
"id": 10,
@@ -31,7 +37,8 @@
"updated_at": "2020-04-10T13:00:53.000000Z",
"created_by": 4,
"updated_by": 1,
"owned_by": 4
"owned_by": 4,
"cover": null
}
],
"total": 3

View File

@@ -10,7 +10,7 @@ const isProd = process.argv[2] === 'production';
// Gather our input files
const entryPoints = {
app: path.join(__dirname, '../../resources/js/app.js'),
app: path.join(__dirname, '../../resources/js/app.ts'),
code: path.join(__dirname, '../../resources/js/code/index.mjs'),
'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'),
markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'),

View File

@@ -0,0 +1,14 @@
// This is a basic transformer stub to help jest handle SVG files.
// Essentially blanks them since we don't really need to involve them
// in our tests (yet).
module.exports = {
process() {
return {
code: 'module.exports = \'\';',
};
},
getCacheKey() {
// The output is always the same.
return 'svgTransform';
},
};

View File

@@ -1,34 +1,38 @@
FROM php:8.3-apache
ENV APACHE_DOCUMENT_ROOT /app/public
WORKDIR /app
RUN <<EOR
# Install additional dependencies
apt-get update
apt-get install -y \
git \
zip \
unzip \
libpng-dev \
libldap2-dev \
libzip-dev \
wait-for-it
rm -rf /var/lib/apt/lists/*
RUN apt-get update && \
apt-get install -y \
git \
zip \
unzip \
libfreetype-dev \
libjpeg62-turbo-dev \
libldap2-dev \
libpng-dev \
libzip-dev \
wait-for-it && \
rm -rf /var/lib/apt/lists/*
# Configure apache
docker-php-ext-configure ldap --with-libdir="lib/$(gcc -dumpmachine)"
docker-php-ext-install pdo_mysql gd ldap zip
pecl install xdebug
docker-php-ext-enable xdebug
a2enmod rewrite
sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf
sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
# Install PHP extensions
RUN docker-php-ext-configure ldap --with-libdir="lib/$(gcc -dumpmachine)" && \
docker-php-ext-configure gd --with-freetype --with-jpeg && \
docker-php-ext-install -j$(nproc) pdo_mysql gd ldap zip && \
pecl install xdebug && \
docker-php-ext-enable xdebug
# Install composer
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Configure apache
RUN a2enmod rewrite && \
sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf && \
sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
# Use the default production configuration and update it as required
mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
sed -i 's/memory_limit = 128M/memory_limit = 512M/g' "$PHP_INI_DIR/php.ini"
EOR
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" && \
sed -i 's/memory_limit = 128M/memory_limit = 512M/g' "$PHP_INI_DIR/php.ini"
ENV APACHE_DOCUMENT_ROOT="/app/public"
WORKDIR /app

View File

@@ -9,7 +9,7 @@ if [[ -n "$1" ]]; then
else
composer install
wait-for-it db:3306 -t 45
php artisan migrate --database=mysql
chown -R www-data:www-data storage
php artisan migrate --database=mysql --force
chown -R www-data storage public/uploads bootstrap/cache
exec apache2-foreground
fi

View File

@@ -3,7 +3,7 @@
All development on BookStack is currently done on the `development` branch.
When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
* [Node.js](https://nodejs.org/en/) v18.0+
* [Node.js](https://nodejs.org/en/) v20.0+
## Building CSS & JavaScript Assets
@@ -82,7 +82,7 @@ If all the conditions are met, you can proceed with the following steps:
1. **Copy `.env.example` to `.env`**, change `APP_KEY` to a random 32 char string and set `APP_ENV` to `local`.
2. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
3. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
3. **Run `chgrp -R docker storage`**. The development container will chown the `storage`, `public/uploads` and `bootstrap/cache` directories to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
4. **Run `docker-compose up`** and wait until the image is built and all database migrations have been done.
5. You can now login with `admin@admin.com` and `password` as password on `localhost:8080` (or another port if specified).

View File

@@ -0,0 +1,160 @@
# Portable ZIP File Format
BookStack provides exports in a "Portable ZIP" which allows the portable transfer, storage, import & export of BookStack content.
This document details the format used, and is intended for our own internal development use in addition to detailing the format for potential external use-cases (readers, apps, import for other platforms etc...).
**Note:** This is not a BookStack backup format! This format misses much of the data that would be needed to re-create/restore a BookStack instance. There are existing better alternative options for this use-case.
## Stability
Following the goals & ideals of BookStack, stability is very important. We aim for this defined format to be stable and forwards compatible, to prevent breakages in use-case due to changes. Here are the general rules we follow in regard to stability & changes:
- New features & properties may be added with any release.
- Where reasonably possible, we will attempt to avoid modifications/removals of existing features/properties.
- Where potentially breaking changes do have to be made, these will be noted in BookStack release/update notes.
The addition of new features/properties alone are not considered as a breaking change to the format. Breaking changes are considered as such where they could impact common/expected use of the existing properties and features we document, they are not considered based upon user assumptions or any possible breakage.
For example if your application, using the format, breaks because we added a new property while you hard-coded your application to use the third property (instead of a property name), then that's on you.
## Format Outline
The format is intended to be very simple, readable and based on open standards that could be easily read/handled in most common programming languages.
The below outlines the structure of the format:
- **ZIP archive container**
- **data.json** - Export data.
- **files/** - Directory containing referenced files.
- *file-a*
- *file-b*
- *...*
## References
Some properties in the export data JSON are indicated as `String reference`, and these are direct references to a file name within the `files/` directory of the ZIP. For example, the below book cover is directly referencing a `files/4a5m4a.jpg` within the ZIP which would be expected to exist.
```json
{
"book": {
"cover": "4a5m4a.jpg"
}
}
```
Within HTML and markdown content, you may require references across to other items within the export content.
This can be done using the following format:
```
[[bsexport:<object>:<reference>]]
```
References are to the `id` for data objects.
Here's an example of each type of such reference that could be used:
```
[[bsexport:image:22]]
[[bsexport:attachment:55]]
[[bsexport:page:40]]
[[bsexport:chapter:2]]
[[bsexport:book:8]]
```
## HTML & Markdown Content
BookStack commonly stores & utilises content in the HTML format.
Properties that expect or provided HTML will either be named `html` or contain `html` in the property name.
While BookStack supports a range of HTML, not all HTML content will be supported by BookStack and be assured to work as desired across all BookStack features.
The HTML supported by BookStack is not yet formally documented, but you can inspect to what the WYSIWYG editor produces as a basis.
Generally, top-level elements should keep to common block formats (p, blockquote, h1, h2 etc...) with no nesting or custom structure apart from common inline elements.
Some areas of BookStack where HTML is used, like book & chapter descriptions, will strictly limit/filter HTML tag & attributes to an allow-list.
For markdown content, in BookStack we target [the commonmark spec](https://commonmark.org/) with the addition of tables & task-lists.
HTML within markdown is supported but not all HTML is assured to work as advised above.
### Content Security
If you're consuming HTML or markdown within an export please consider that the content is not assured to be safe, even if provided directly by a BookStack instance. It's best to treat such content as potentially unsafe.
By default, BookStack performs some basic filtering to remove scripts among other potentially dangerous elements but this is not foolproof. BookStack itself relies on additional security mechanisms such as [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) to help prevent a range of exploits.
## Export Data - `data.json`
The `data.json` file is a JSON format file which contains all structured data for the export. The properties are as follows:
- `instance` - [Instance](#instance) Object, optional, details of the export source instance.
- `exported_at` - String, optional, full ISO 8601 datetime of when the export was created.
- `book` - [Book](#book) Object, optional, book export data.
- `chapter` - [Chapter](#chapter) Object, optional, chapter export data.
- `page` - [Page](#page) Object, optional, page export data.
Either `book`, `chapter` or `page` will exist depending on export type. You'd want to check for each to check what kind of export this is, and if it's an export you can handle. It's possible that other options are added in the future (`books` for a range of books for example) so it'd be wise to specifically check for properties that can be handled, otherwise error to indicate lack of support.
## Data Objects
The below details the objects & their properties used in Application Data.
#### Instance
These details are informational regarding the exporting BookStack instance from where an export was created from.
- `id` - String, required, unique identifier for the BookStack instance.
- `version` - String, required, BookStack version of the export source instance.
#### Book
- `id` - Number, optional, original ID for the book from exported system.
- `name` - String, required, name/title of the book.
- `description_html` - String, optional, HTML description content.
- `cover` - String reference, optional, reference to book cover image.
- `chapters` - [Chapter](#chapter) array, optional, chapters within this book.
- `pages` - [Page](#page) array, optional, direct child pages for this book.
- `tags` - [Tag](#tag) array, optional, tags assigned to this book.
The `pages` are not all pages within the book, just those that are direct children (not in a chapter). To build an ordered mixed chapter/page list for the book, as what you'd see in BookStack, you'd need to combine `chapters` and `pages` together and sort by their `priority` value (low to high).
#### Chapter
- `id` - Number, optional, original ID for the chapter from exported system.
- `name` - String, required, name/title of the chapter.
- `description_html` - String, optional, HTML description content.
- `priority` - Number, optional, integer order for when shown within a book (shown low to high).
- `pages` - [Page](#page) array, optional, pages within this chapter.
- `tags` - [Tag](#tag) array, optional, tags assigned to this chapter.
#### Page
- `id` - Number, optional, original ID for the page from exported system.
- `name` - String, required, name/title of the page.
- `html` - String, optional, page HTML content.
- `markdown` - String, optional, user markdown content for this page.
- `priority` - Number, optional, integer order for when shown within a book (shown low to high).
- `attachments` - [Attachment](#attachment) array, optional, attachments uploaded to this page.
- `images` - [Image](#image) array, optional, images used in this page.
- `tags` - [Tag](#tag) array, optional, tags assigned to this page.
To define the page content, either `markdown` or `html` should be provided. Ideally these should be limited to the range of markdown and HTML which BookStack supports. See the ["HTML & Markdown Content"](#html--markdown-content) section.
The page editor type, and edit content will be determined by what content is provided. If non-empty `markdown` is provided, the page will be assumed as a markdown editor page (where permissions allow) and the HTML will be rendered from the markdown content. Otherwise, the provided `html` will be used as editor & display content.
#### Image
- `id` - Number, optional, original ID for the page from exported system.
- `name` - String, required, name of image.
- `file` - String reference, required, reference to image file.
- `type` - String, required, must be 'gallery' or 'drawio'
File must be an image type accepted by BookStack (png, jpg, gif, webp).
Images of type 'drawio' are expected to be png with draw.io drawing data
embedded within it.
#### Attachment
- `id` - Number, optional, original ID for the attachment from exported system.
- `name` - String, required, name of attachment.
- `link` - String, semi-optional, URL of attachment.
- `file` - String reference, semi-optional, reference to attachment file.
Either `link` or `file` must be present, as that will determine the type of attachment.
#### Tag
- `name` - String, required, name of the tag.
- `value` - String, optional, value of the tag (can be empty).

View File

@@ -507,6 +507,12 @@ Copyright: Copyright (c) 2011 Debuggable Limited <*****@**********.***>
Source: git://github.com/felixge/node-delayed-stream.git
Link: https://github.com/felixge/node-delayed-stream
-----------
detect-libc
License: Apache-2.0
License File: node_modules/detect-libc/LICENSE
Source: git://github.com/lovell/detect-libc
Link: git://github.com/lovell/detect-libc
-----------
detect-newline
License: MIT
License File: node_modules/detect-newline/license
@@ -1819,6 +1825,13 @@ Copyright: Copyright (c) 2018 Tobias Reich
Source: https://github.com/electerious/nice-try.git
Link: https://github.com/electerious/nice-try
-----------
node-addon-api
License: MIT
License File: node_modules/node-addon-api/LICENSE.md
Copyright: Copyright (c) 2017 [Node.js API collaborators](https://github.com/nodejs/node-addon-api#collaborators)
Source: git://github.com/nodejs/node-addon-api.git
Link: https://github.com/nodejs/node-addon-api
-----------
node-int64
License: MIT
License File: node_modules/node-int64/LICENSE
@@ -3525,6 +3538,11 @@ Copyright: Copyright (C) 2018 by Marijn Haverbeke <******@*********.******> and
Source: https://github.com/lezer-parser/xml.git
Link: https://github.com/lezer-parser/xml.git
-----------
@marijn/find-cluster-break
License: MIT
Source: git+https://github.com/marijnh/find-cluster-break.git
Link: https://github.com/marijnh/find-cluster-break#readme
-----------
@nodelib/fs.scandir
License: MIT
License File: node_modules/@nodelib/fs.scandir/LICENSE
@@ -3546,6 +3564,27 @@ Copyright: Copyright (c) Denis Malinochkin
Source: https://github.com/nodelib/nodelib/tree/master/packages/fs/fs.walk
Link: https://github.com/nodelib/nodelib/tree/master/packages/fs/fs.walk
-----------
@parcel/watcher-linux-x64-glibc
License: MIT
License File: node_modules/@parcel/watcher-linux-x64-glibc/LICENSE
Copyright: Copyright (c) 2017-present Devon Govett
Source: https://github.com/parcel-bundler/watcher.git
Link: https://github.com/parcel-bundler/watcher.git
-----------
@parcel/watcher-linux-x64-musl
License: MIT
License File: node_modules/@parcel/watcher-linux-x64-musl/LICENSE
Copyright: Copyright (c) 2017-present Devon Govett
Source: https://github.com/parcel-bundler/watcher.git
Link: https://github.com/parcel-bundler/watcher.git
-----------
@parcel/watcher
License: MIT
License File: node_modules/@parcel/watcher/LICENSE
Copyright: Copyright (c) 2017-present Devon Govett
Source: https://github.com/parcel-bundler/watcher.git
Link: https://github.com/parcel-bundler/watcher.git
-----------
@rtsao/scc
License: MIT
License File: node_modules/@rtsao/scc/LICENSE

View File

@@ -202,7 +202,7 @@ Link: https://github.com/intervention/gif
intervention/image
License: MIT
License File: vendor/intervention/image/LICENSE
Copyright: Copyright (c) 2013-2024 Oliver Vogel
Copyright: Copyright (c) 2013-present Oliver Vogel
Source: https://github.com/Intervention/image.git
Link: https://image.intervention.io/
-----------
@@ -307,7 +307,7 @@ Link: https://github.com/thephpleague/oauth1-client.git
league/oauth2-client
License: MIT
License File: vendor/league/oauth2-client/LICENSE
Copyright: Copyright (c) 2013-2020 Alex Bilbie <*****@**********.***>
Copyright: Copyright (c) 2013-2023 Alex Bilbie <*****@**********.***>
Source: https://github.com/thephpleague/oauth2-client.git
Link: https://github.com/thephpleague/oauth2-client.git
-----------
@@ -415,7 +415,7 @@ predis/predis
License: MIT
License File: vendor/predis/predis/LICENSE
Copyright: Copyright (c) 2009-2020 Daniele Alessandri (original work)
Copyright (c) 2021-2023 Till Krüss (modified work)
Copyright (c) 2021-2024 Till Krüss (modified work)
Source: https://github.com/predis/predis.git
Link: http://github.com/predis/predis
-----------
@@ -514,7 +514,7 @@ Link: https://github.com/ramsey/uuid.git
robrichards/xmlseclibs
License: BSD-3-Clause
License File: vendor/robrichards/xmlseclibs/LICENSE
Copyright: Copyright (c) 2007-2019, Robert Richards <*********@*********.***>.
Copyright: Copyright (c) 2007-2024, Robert Richards <*********@*********.***>.
Source: https://github.com/robrichards/xmlseclibs.git
Link: https://github.com/robrichards/xmlseclibs
-----------
@@ -560,9 +560,9 @@ Link: https://github.com/SocialiteProviders/Twitch.git
ssddanbrown/htmldiff
License: MIT
License File: vendor/ssddanbrown/htmldiff/license.md
Copyright: Copyright (c) 2020 Nathan Herald, Rohland de Charmoy, Dan Brown
Source: https://github.com/ssddanbrown/HtmlDiff.git
Link: https://github.com/ssddanbrown/htmldiff
Copyright: Copyright (c) 2024 Nathan Herald, Rohland de Charmoy, Dan Brown
Source: https://codeberg.org/danb/HtmlDiff
Link: https://codeberg.org/danb/HtmlDiff
-----------
ssddanbrown/symfony-mailer
License: MIT

View File

@@ -185,6 +185,7 @@ const config: Config = {
// A map from regular expressions to paths to transformers
transform: {
"^.+.tsx?$": ["ts-jest",{}],
"^.+.svg$": ["<rootDir>/dev/build/svg-blank-transform.js",{}],
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation

View File

@@ -84,6 +84,14 @@ return [
'webhook_delete' => 'حذف webhook',
'webhook_delete_notification' => 'تم حذف Webhook بنجاح',
// Imports
'import_create' => 'created import',
'import_create_notification' => 'Import successfully uploaded',
'import_run' => 'updated import',
'import_run_notification' => 'Content successfully imported',
'import_delete' => 'deleted import',
'import_delete_notification' => 'Import successfully deleted',
// Users
'user_create' => 'إنشاء مستخدم',
'user_create_notification' => 'تم انشاء الحساب',

View File

@@ -89,8 +89,8 @@ return [
'mfa_setup_action' => 'إعداد (تنصيب)',
'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
'mfa_option_totp_title' => 'تطبيق الجوال',
'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
'mfa_option_backup_codes_title' => 'Backup Codes',
'mfa_option_totp_desc' => 'لاستخدام المصادقة المتعددة العوامل، ستحتاج إلى تطبيق محمول يدعم TOTP مثل Google Authenticator أو Authy أو Microsoft Authenticer.',
'mfa_option_backup_codes_title' => 'رموز النسخ الاحتياطي',
'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.',
'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
'mfa_gen_backup_codes_title' => 'Backup Codes Setup',

View File

@@ -163,6 +163,8 @@ return [
'about' => 'About the editor',
'about_title' => 'About the WYSIWYG Editor',
'editor_license' => 'Editor License & Copyright',
'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.',
'editor_lexical_license_link' => 'Full license details can be found here.',
'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.',
'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.',
'save_continue' => 'Save Page & Continue',

View File

@@ -39,9 +39,30 @@ return [
'export_pdf' => 'ملف PDF',
'export_text' => 'ملف نص عادي',
'export_md' => 'Markdown File',
'export_zip' => 'Portable ZIP',
'default_template' => 'Default Page Template',
'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.',
'default_template_select' => 'Select a template page',
'import' => 'Import',
'import_validate' => 'Validate Import',
'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.',
'import_zip_select' => 'Select ZIP file to upload',
'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:',
'import_pending' => 'Pending Imports',
'import_pending_none' => 'No imports have been started.',
'import_continue' => 'Continue Import',
'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.',
'import_details' => 'Import Details',
'import_run' => 'Run Import',
'import_size' => ':size Import ZIP Size',
'import_uploaded_at' => 'Uploaded :relativeTime',
'import_uploaded_by' => 'Uploaded by',
'import_location' => 'Import Location',
'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.',
'import_delete_confirm' => 'Are you sure you want to delete this import?',
'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
'import_errors' => 'Import Errors',
'import_errors_desc' => 'The follow errors occurred during the import attempt:',
// Permissions and restrictions
'permissions' => 'الأذونات',

View File

@@ -105,6 +105,18 @@ return [
'app_down' => ':appName لا يعمل حالياً',
'back_soon' => 'سيعود للعمل قريباً.',
// Import
'import_zip_cant_read' => 'Could not read ZIP file.',
'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
'import_validation_failed' => 'Import ZIP failed to validate with errors:',
'import_zip_failed_notification' => 'Failed to import ZIP file.',
'import_perms_books' => 'You are lacking the required permissions to create books.',
'import_perms_chapters' => 'You are lacking the required permissions to create chapters.',
'import_perms_pages' => 'You are lacking the required permissions to create pages.',
'import_perms_images' => 'You are lacking the required permissions to create images.',
'import_perms_attachments' => 'You are lacking the required permission to create attachments.',
// API errors
'api_no_authorization_found' => 'لم يتم العثور على رمز ترخيص مميز في الطلب',
'api_bad_authorization_format' => 'تم العثور على رمز ترخيص مميز في الطلب ولكن يبدو أن التنسيق غير صحيح',

View File

@@ -162,6 +162,7 @@ return [
'role_access_api' => 'الوصول إلى واجهة برمجة تطبيقات النظام API',
'role_manage_settings' => 'إدارة إعدادات التطبيق',
'role_export_content' => 'Export content',
'role_import_content' => 'Import content',
'role_editor_change' => 'Change page editor',
'role_notifications' => 'Receive & manage notifications',
'role_asset' => 'أذونات الأصول',

View File

@@ -105,6 +105,11 @@ return [
'url' => 'صيغة :attribute غير صالحة.',
'uploaded' => 'تعذر تحميل الملف. قد لا يقبل الخادم ملفات بهذا الحجم.',
'zip_file' => 'The :attribute needs to reference a file within the ZIP.',
'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.',
'zip_model_expected' => 'Data object expected but ":type" found.',
'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.',
// Custom validation lines
'custom' => [
'password-confirm' => [

View File

@@ -84,6 +84,14 @@ return [
'webhook_delete' => 'изтрита уебкука',
'webhook_delete_notification' => 'Уебкуката е изтрита успешно',
// Imports
'import_create' => 'created import',
'import_create_notification' => 'Import successfully uploaded',
'import_run' => 'updated import',
'import_run_notification' => 'Content successfully imported',
'import_delete' => 'deleted import',
'import_delete_notification' => 'Import successfully deleted',
// Users
'user_create' => 'created user',
'user_create_notification' => 'User successfully created',

View File

@@ -163,6 +163,8 @@ return [
'about' => 'За редактора',
'about_title' => 'Относно визуалния редактор',
'editor_license' => 'Лиценз, авторски и сходни права на редактора',
'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.',
'editor_lexical_license_link' => 'Full license details can be found here.',
'editor_tiny_license' => 'Този редактор е изграден посредством :tinyLink, което е предоставен под лиценз MIT.',
'editor_tiny_license_link' => 'Авторското и сходните му права, както и лицензът на TinyMCE, могат да бъдат намерени тук.',
'save_continue' => 'Запази страницата и продължи',

View File

@@ -39,9 +39,30 @@ return [
'export_pdf' => 'PDF файл',
'export_text' => 'Обикновен текстов файл',
'export_md' => 'Markdown файл',
'export_zip' => 'Portable ZIP',
'default_template' => 'Default Page Template',
'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.',
'default_template_select' => 'Select a template page',
'import' => 'Import',
'import_validate' => 'Validate Import',
'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.',
'import_zip_select' => 'Select ZIP file to upload',
'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:',
'import_pending' => 'Pending Imports',
'import_pending_none' => 'No imports have been started.',
'import_continue' => 'Continue Import',
'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.',
'import_details' => 'Import Details',
'import_run' => 'Run Import',
'import_size' => ':size Import ZIP Size',
'import_uploaded_at' => 'Uploaded :relativeTime',
'import_uploaded_by' => 'Uploaded by',
'import_location' => 'Import Location',
'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.',
'import_delete_confirm' => 'Are you sure you want to delete this import?',
'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
'import_errors' => 'Import Errors',
'import_errors_desc' => 'The follow errors occurred during the import attempt:',
// Permissions and restrictions
'permissions' => 'Права',

View File

@@ -105,6 +105,18 @@ return [
'app_down' => ':appName не е достъпно в момента',
'back_soon' => 'Ще се върне обратно онлайн скоро.',
// Import
'import_zip_cant_read' => 'Could not read ZIP file.',
'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
'import_validation_failed' => 'Import ZIP failed to validate with errors:',
'import_zip_failed_notification' => 'Failed to import ZIP file.',
'import_perms_books' => 'You are lacking the required permissions to create books.',
'import_perms_chapters' => 'You are lacking the required permissions to create chapters.',
'import_perms_pages' => 'You are lacking the required permissions to create pages.',
'import_perms_images' => 'You are lacking the required permissions to create images.',
'import_perms_attachments' => 'You are lacking the required permission to create attachments.',
// API errors
'api_no_authorization_found' => 'Но беше намерен код за достъп в заявката',
'api_bad_authorization_format' => 'В заявката имаше код за достъп, но формата изглежда е неправилен',

View File

@@ -162,6 +162,7 @@ return [
'role_access_api' => 'Достъп до API на системата',
'role_manage_settings' => 'Управление на настройките на приложението',
'role_export_content' => 'Експортирай съдържанието',
'role_import_content' => 'Import content',
'role_editor_change' => 'Change page editor',
'role_notifications' => 'Receive & manage notifications',
'role_asset' => 'Настройки за достъп до активи',

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