]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'fix/markdown-export' into development
authorDan Brown <redacted>
Mon, 2 Dec 2024 11:50:15 +0000 (11:50 +0000)
committerDan Brown <redacted>
Mon, 2 Dec 2024 11:50:15 +0000 (11:50 +0000)
149 files changed:
.github/translators.txt
app/Access/LdapService.php
app/Access/Oidc/OidcUserinfoResponse.php
app/Activity/ActivityType.php
app/Entities/Models/Chapter.php
app/Entities/Repos/PageRepo.php
app/Entities/Tools/Cloner.php
app/Entities/Tools/PageIncludeParser.php
app/Exceptions/ZipExportException.php [new file with mode: 0644]
app/Exceptions/ZipImportException.php [new file with mode: 0644]
app/Exceptions/ZipValidationException.php [new file with mode: 0644]
app/Exports/Controllers/BookExportApiController.php [moved from app/Entities/Controllers/BookExportApiController.php with 95% similarity]
app/Exports/Controllers/BookExportController.php [moved from app/Entities/Controllers/BookExportController.php with 74% similarity]
app/Exports/Controllers/ChapterExportApiController.php [moved from app/Entities/Controllers/ChapterExportApiController.php with 95% similarity]
app/Exports/Controllers/ChapterExportController.php [moved from app/Entities/Controllers/ChapterExportController.php with 78% similarity]
app/Exports/Controllers/ImportController.php [new file with mode: 0644]
app/Exports/Controllers/PageExportApiController.php [moved from app/Entities/Controllers/PageExportApiController.php with 95% similarity]
app/Exports/Controllers/PageExportController.php [moved from app/Entities/Controllers/PageExportController.php with 80% similarity]
app/Exports/ExportFormatter.php [moved from app/Entities/Tools/ExportFormatter.php with 98% similarity]
app/Exports/Import.php [new file with mode: 0644]
app/Exports/ImportRepo.php [new file with mode: 0644]
app/Exports/PdfGenerator.php [moved from app/Entities/Tools/PdfGenerator.php with 99% similarity]
app/Exports/ZipExports/Models/ZipExportAttachment.php [new file with mode: 0644]
app/Exports/ZipExports/Models/ZipExportBook.php [new file with mode: 0644]
app/Exports/ZipExports/Models/ZipExportChapter.php [new file with mode: 0644]
app/Exports/ZipExports/Models/ZipExportImage.php [new file with mode: 0644]
app/Exports/ZipExports/Models/ZipExportModel.php [new file with mode: 0644]
app/Exports/ZipExports/Models/ZipExportPage.php [new file with mode: 0644]
app/Exports/ZipExports/Models/ZipExportTag.php [new file with mode: 0644]
app/Exports/ZipExports/ZipExportBuilder.php [new file with mode: 0644]
app/Exports/ZipExports/ZipExportFiles.php [new file with mode: 0644]
app/Exports/ZipExports/ZipExportReader.php [new file with mode: 0644]
app/Exports/ZipExports/ZipExportReferences.php [new file with mode: 0644]
app/Exports/ZipExports/ZipExportValidator.php [new file with mode: 0644]
app/Exports/ZipExports/ZipFileReferenceRule.php [new file with mode: 0644]
app/Exports/ZipExports/ZipImportReferences.php [new file with mode: 0644]
app/Exports/ZipExports/ZipImportRunner.php [new file with mode: 0644]
app/Exports/ZipExports/ZipReferenceParser.php [new file with mode: 0644]
app/Exports/ZipExports/ZipUniqueIdRule.php [new file with mode: 0644]
app/Exports/ZipExports/ZipValidationHelper.php [new file with mode: 0644]
app/Http/Controller.php
app/Http/RangeSupportedStream.php
app/References/ModelResolvers/AttachmentModelResolver.php [new file with mode: 0644]
app/References/ModelResolvers/ImageModelResolver.php [new file with mode: 0644]
app/Uploads/AttachmentService.php
app/Uploads/FileStorage.php [new file with mode: 0644]
app/Uploads/ImageService.php
app/Uploads/ImageStorage.php
app/Uploads/ImageStorageDisk.php
app/Util/HtmlDocument.php
composer.json
composer.lock
database/factories/Exports/ImportFactory.php [new file with mode: 0644]
database/migrations/2021_08_28_161743_add_export_role_permission.php
database/migrations/2024_10_29_114420_add_import_role_permission.php [new file with mode: 0644]
database/migrations/2024_11_02_160700_create_imports_table.php [new file with mode: 0644]
database/migrations/2024_11_27_171039_add_instance_id_setting.php [new file with mode: 0644]
dev/build/esbuild.js
dev/docker/Dockerfile
dev/docker/entrypoint.app.sh
dev/docs/development.md
dev/docs/portable-zip-file-format.md [new file with mode: 0644]
dev/licensing/php-library-licenses.txt
lang/cs/activities.php
lang/cs/entities.php
lang/da/activities.php
lang/da/settings.php
lang/en/activities.php
lang/en/entities.php
lang/en/errors.php
lang/en/settings.php
lang/en/validation.php
lang/ka/activities.php
lang/nn/activities.php
lang/nn/auth.php
lang/uk/common.php
lang/uk/entities.php
lang/uk/errors.php
lang/zh_TW/validation.php
resources/js/app.js [deleted file]
resources/js/app.ts [new file with mode: 0644]
resources/js/components/add-remove-rows.js
resources/js/components/ajax-delete-row.js
resources/js/components/ajax-form.js
resources/js/components/attachments.js
resources/js/components/auto-suggest.js
resources/js/components/book-sort.js
resources/js/components/chapter-contents.js
resources/js/components/code-editor.js
resources/js/components/collapsible.js
resources/js/components/confirm-dialog.js
resources/js/components/dropdown-search.js
resources/js/components/dropdown.js
resources/js/components/dropzone.js
resources/js/components/entity-permissions.js
resources/js/components/entity-search.js
resources/js/components/entity-selector.js
resources/js/components/event-emit-select.js
resources/js/components/expand-toggle.js
resources/js/components/global-search.js
resources/js/components/image-manager.js
resources/js/components/index.ts [moved from resources/js/components/index.js with 98% similarity]
resources/js/components/loading-button.ts [new file with mode: 0644]
resources/js/components/optional-input.js
resources/js/components/page-comment.js
resources/js/components/page-comments.js
resources/js/components/page-display.js
resources/js/components/page-editor.js
resources/js/components/pointer.js
resources/js/components/popup.js
resources/js/components/template-manager.js
resources/js/components/user-select.js
resources/js/global.d.ts
resources/js/markdown/codemirror.js
resources/js/services/animations.ts [moved from resources/js/services/animations.js with 63% similarity]
resources/js/services/dom.ts [moved from resources/js/services/dom.js with 63% similarity]
resources/js/services/keyboard-navigation.ts [moved from resources/js/services/keyboard-navigation.js with 66% similarity]
resources/js/services/util.js [deleted file]
resources/js/services/util.ts [new file with mode: 0644]
resources/js/wysiwyg-tinymce/plugin-drawio.js
resources/sass/_codemirror.scss
resources/sass/_forms.scss
resources/sass/styles.scss
resources/views/books/index.blade.php
resources/views/entities/export-menu.blade.php
resources/views/entities/selector.blade.php
resources/views/exports/import-show.blade.php [new file with mode: 0644]
resources/views/exports/import.blade.php [new file with mode: 0644]
resources/views/exports/parts/import-item.blade.php [new file with mode: 0644]
resources/views/exports/parts/import.blade.php [new file with mode: 0644]
resources/views/form/errors.blade.php
resources/views/settings/roles/parts/form.blade.php
routes/api.php
routes/web.php
tests/Auth/LdapTest.php
tests/Auth/OidcTest.php
tests/Entity/ExportTest.php [deleted file]
tests/Exports/ExportUiTest.php [new file with mode: 0644]
tests/Exports/HtmlExportTest.php [new file with mode: 0644]
tests/Exports/MarkdownExportTest.php [new file with mode: 0644]
tests/Exports/PdfExportTest.php [new file with mode: 0644]
tests/Exports/TextExportTest.php [new file with mode: 0644]
tests/Exports/ZipExportTest.php [new file with mode: 0644]
tests/Exports/ZipExportValidatorTest.php [new file with mode: 0644]
tests/Exports/ZipImportRunnerTest.php [new file with mode: 0644]
tests/Exports/ZipImportTest.php [new file with mode: 0644]
tests/Exports/ZipResultData.php [new file with mode: 0644]
tests/Exports/ZipTestHelper.php [new file with mode: 0644]
tests/Uploads/AttachmentTest.php

index 65ea00fe8f7f627fc016096e8c6b088155eb8651..9699be70f8bc382a2676209b560d415b66c13089 100644 (file)
@@ -453,3 +453,5 @@ 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
index 365cb1db0151b9a109bb5802dc4f57a1f8eaabcf..e5037ad2f95699243f495943fbb667b26d96d3a3 100644 (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,
index 9aded654e31b7ee7ce754556e5272f0f7b9aaf52..33b8ec80665523a88a63ef9f3438e04546ad6d64 100644 (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);
         }
index 09b2ae73c561e9b8be68292f370d13afa5dd4de9..5ec9b9cf0dc7f452040b2ca3278c24d0ed0c9855 100644 (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.
      */
index c926aaa647a7d75501b55880e1cd2c834990f8fc..088d199da675286af90ea73c3f24ca396ede2347 100644 (file)
@@ -60,6 +60,7 @@ class Chapter extends BookChild
 
     /**
      * Get the visible pages in this chapter.
+     * @returns Collection<Page>
      */
     public function getVisiblePages(): Collection
     {
index 1bc15392cec7b4478372b761c4179e3fa364f297..68b1c398f801d22ac3d74211f2e10714505083f4 100644 (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');
index 2030b050c4b19f39e01b1c6312c3e381405ff855..2be6083e3ddcb810871ab42ad973070d1f41fd8e 100644 (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,
+    ) {
     }
 
     /**
index dad7c29e60ee3392de7b6d56726fee5455074eb5..e0b89f158704298702122f9839898be6c9e6ffde 100644 (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);
         }
diff --git a/app/Exceptions/ZipExportException.php b/app/Exceptions/ZipExportException.php
new file mode 100644 (file)
index 0000000..b2c811e
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+namespace BookStack\Exceptions;
+
+class ZipExportException extends \Exception
+{
+}
diff --git a/app/Exceptions/ZipImportException.php b/app/Exceptions/ZipImportException.php
new file mode 100644 (file)
index 0000000..452365c
--- /dev/null
@@ -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);
+    }
+}
diff --git a/app/Exceptions/ZipValidationException.php b/app/Exceptions/ZipValidationException.php
new file mode 100644 (file)
index 0000000..aaaee79
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace BookStack\Exceptions;
+
+class ZipValidationException extends \Exception
+{
+    public function __construct(
+        public array $errors
+    ) {
+        parent::__construct();
+    }
+}
similarity index 95%
rename from app/Entities/Controllers/BookExportApiController.php
rename to app/Exports/Controllers/BookExportApiController.php
index 1161ddb8886964423f088630424317225914eb74..164946b0c781d30d472bcda8cfb4066e9b71763d 100644 (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;
 
similarity index 74%
rename from app/Entities/Controllers/BookExportController.php
rename to app/Exports/Controllers/BookExportController.php
index 5c1a964c1e1037c2d60bd789cb5a049acb71b1c2..f726175a086acab697ce6e05575f69f90b58be8f 100644 (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));
+    }
 }
similarity index 95%
rename from app/Entities/Controllers/ChapterExportApiController.php
rename to app/Exports/Controllers/ChapterExportApiController.php
index ceb2522b2118212657a9a1bbab666fe42c28581c..9914e2b7fbed242405e2bd41e0555674eb9f5ca0 100644 (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;
 
similarity index 78%
rename from app/Entities/Controllers/ChapterExportController.php
rename to app/Exports/Controllers/ChapterExportController.php
index ead601ab46b432ca541c74df4d77a15f7cd8aeda..0d7a5c0d195ec94181cbacca251a632d2008e7c0 100644 (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));
+    }
 }
diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php
new file mode 100644 (file)
index 0000000..b938dac
--- /dev/null
@@ -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');
+    }
+}
similarity index 95%
rename from app/Entities/Controllers/PageExportApiController.php
rename to app/Exports/Controllers/PageExportApiController.php
index 693760bc8e727ddb6ce13a8c004f3a808f816670..c6e20b615d2426f92af9afbd03d2cceac492b0d2 100644 (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;
 
similarity index 80%
rename from app/Entities/Controllers/PageExportController.php
rename to app/Exports/Controllers/PageExportController.php
index be97f1930bdd28e61f5f14d98647b336cc1439cf..34e67ffcf7075ede6b30a2842789ef8fa204c7f0 100644 (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));
+    }
 }
similarity index 98%
rename from app/Entities/Tools/ExportFormatter.php
rename to app/Exports/ExportFormatter.php
index 0af68b8db3cfcd3f4f9957c0a949259a2144593b..85ac7d2c9e979dee8c127ad6216eb45de108bbc5 100644 (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;
diff --git a/app/Exports/Import.php b/app/Exports/Import.php
new file mode 100644 (file)
index 0000000..9c1771c
--- /dev/null
@@ -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,
+        };
+    }
+}
diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php
new file mode 100644 (file)
index 0000000..f72386c
--- /dev/null
@@ -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);
+    }
+}
similarity index 99%
rename from app/Entities/Tools/PdfGenerator.php
rename to app/Exports/PdfGenerator.php
index 79cd1b02f7ff1daac18cbd30c6c1c3679ed0c29f..0524e063fb259ca22e8e9a6e3c9a22352bb6e2ac 100644 (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;
 
diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php
new file mode 100644 (file)
index 0000000..4f5b2f2
--- /dev/null
@@ -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;
+    }
+}
diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php
new file mode 100644 (file)
index 0000000..4f641d2
--- /dev/null
@@ -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) {
+                $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;
+    }
+}
diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php
new file mode 100644 (file)
index 0000000..bf2dc78
--- /dev/null
@@ -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;
+    }
+}
diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php
new file mode 100644 (file)
index 0000000..e0e7d11
--- /dev/null
@@ -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;
+    }
+}
diff --git a/app/Exports/ZipExports/Models/ZipExportModel.php b/app/Exports/ZipExports/Models/ZipExportModel.php
new file mode 100644 (file)
index 0000000..d3a8c35
--- /dev/null
@@ -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;
+}
diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php
new file mode 100644 (file)
index 0000000..097443d
--- /dev/null
@@ -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;
+    }
+}
diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php
new file mode 100644 (file)
index 0000000..6b4720f
--- /dev/null
@@ -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;
+    }
+}
diff --git a/app/Exports/ZipExports/ZipExportBuilder.php b/app/Exports/ZipExports/ZipExportBuilder.php
new file mode 100644 (file)
index 0000000..4c5c638
--- /dev/null
@@ -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;
+    }
+}
diff --git a/app/Exports/ZipExports/ZipExportFiles.php b/app/Exports/ZipExports/ZipExportFiles.php
new file mode 100644 (file)
index 0000000..8f0a6bd
--- /dev/null
@@ -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);
+        }
+    }
+}
diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php
new file mode 100644 (file)
index 0000000..c3d5c23
--- /dev/null
@@ -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.");
+    }
+}
diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php
new file mode 100644 (file)
index 0000000..bf5e021
--- /dev/null
@@ -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;
+    }
+}
diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php
new file mode 100644 (file)
index 0000000..889804f
--- /dev/null
@@ -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;
+    }
+}
diff --git a/app/Exports/ZipExports/ZipFileReferenceRule.php b/app/Exports/ZipExports/ZipFileReferenceRule.php
new file mode 100644 (file)
index 0000000..90e78c0
--- /dev/null
@@ -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
+                ]);
+            }
+        }
+    }
+}
diff --git a/app/Exports/ZipExports/ZipImportReferences.php b/app/Exports/ZipExports/ZipImportReferences.php
new file mode 100644 (file)
index 0000000..da0581d
--- /dev/null
@@ -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;
+    }
+}
diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php
new file mode 100644 (file)
index 0000000..d25a162
--- /dev/null
@@ -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;
+    }
+}
diff --git a/app/Exports/ZipExports/ZipReferenceParser.php b/app/Exports/ZipExports/ZipReferenceParser.php
new file mode 100644 (file)
index 0000000..a6560e3
--- /dev/null
@@ -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>\"'=?#()]/";
+    }
+}
diff --git a/app/Exports/ZipExports/ZipUniqueIdRule.php b/app/Exports/ZipExports/ZipUniqueIdRule.php
new file mode 100644 (file)
index 0000000..ea2b253
--- /dev/null
@@ -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]);
+        }
+    }
+}
diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php
new file mode 100644 (file)
index 0000000..fd9cd78
--- /dev/null
@@ -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;
+    }
+}
index 8facf5dab3c3331d1538b373b68b269d6c6f53b4..090cf523ad28051751f0cca3b325890cf99e7332 100644 (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);
     }
index 300f4488cf44453d4f18f5c27207da8b6f12ed1b..fce1e9acce30cbc187756c5ef9dd54b7e79f51dd 100644 (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;
diff --git a/app/References/ModelResolvers/AttachmentModelResolver.php b/app/References/ModelResolvers/AttachmentModelResolver.php
new file mode 100644 (file)
index 0000000..e870d51
--- /dev/null
@@ -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);
+    }
+}
diff --git a/app/References/ModelResolvers/ImageModelResolver.php b/app/References/ModelResolvers/ImageModelResolver.php
new file mode 100644 (file)
index 0000000..2c6c9fe
--- /dev/null
@@ -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;
+    }
+}
index bd319fbd795af717c4c026d63b9b8bc58ea7fabd..033f2334104b44f008d0ce29ecdc84bc68a33117 100644 (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);
     }
 
     /**
@@ -200,15 +151,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 +163,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)];
     }
diff --git a/app/Uploads/FileStorage.php b/app/Uploads/FileStorage.php
new file mode 100644 (file)
index 0000000..e6ac368
--- /dev/null
@@ -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;
+    }
+}
index 8d8da61ec185b42691fde6bd1673abeb44a307ad..038e6aa417c839b8f794038ec2bd40545f71e111 100644 (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.
index dc4abc0f281b6ba5fbbc6828411bd4abef40b3bb..ddaa26a9400c343b2dea6bf32dd118f1a5e79bfc 100644 (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, '/');
     }
 }
index 798b72abdbf9d9e0384b66c2508dccf89cf39530..8df702e0d94183b23248a441935e6723d199c4d1 100644 (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.
index b8c53d43916294dccf63ed3821222f2888edf22d..7517955b9f60b2e14afbd91fddb7123fc9fb2095 100644 (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.
      */
index 5c54774f1e274f355178d41b78b60fae33ec853f..b8d8da9e72ff0c732e3572165c91942fd040b5cb 100644 (file)
         "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",
index f2d585f8fb2fa069724bc3af4e6abec020d78390..f744f76208c9981152467d485a821691a9fc5981 100644 (file)
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "b24a1daf815b6910b51a2acc5e2d38e7",
+    "content-hash": "4a5a18010b7f4b32b7f0ae2a3e6305bb",
     "packages": [
         {
             "name": "aws/aws-crt-php",
         },
         {
             "name": "aws/aws-sdk-php",
-            "version": "3.325.4",
+            "version": "3.331.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/aws/aws-sdk-php.git",
-                "reference": "86372984539f25485718059f997a4ea12c05ba0a"
+                "reference": "0f8b3f63ba7b296afedcb3e6a43ce140831b9400"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/86372984539f25485718059f997a4ea12c05ba0a",
-                "reference": "86372984539f25485718059f997a4ea12c05ba0a",
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0f8b3f63ba7b296afedcb3e6a43ce140831b9400",
+                "reference": "0f8b3f63ba7b296afedcb3e6a43ce140831b9400",
                 "shasum": ""
             },
             "require": {
                 "nette/neon": "^2.3",
                 "paragonie/random_compat": ">= 2",
                 "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5",
-                "psr/cache": "^1.0",
-                "psr/simple-cache": "^1.0",
+                "psr/cache": "^1.0 || ^2.0 || ^3.0",
+                "psr/simple-cache": "^1.0 || ^2.0 || ^3.0",
                 "sebastian/comparator": "^1.2.3 || ^4.0",
                 "yoast/phpunit-polyfills": "^1.0"
             },
             "support": {
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
-                "source": "https://github.com/aws/aws-sdk-php/tree/3.325.4"
+                "source": "https://github.com/aws/aws-sdk-php/tree/3.331.0"
             },
-            "time": "2024-11-07T19:25:01+00:00"
+            "time": "2024-11-27T19:12:58+00:00"
         },
         {
             "name": "bacon/bacon-qr-code",
         },
         {
             "name": "firebase/php-jwt",
-            "version": "v6.10.1",
+            "version": "v6.10.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/firebase/php-jwt.git",
-                "reference": "500501c2ce893c824c801da135d02661199f60c5"
+                "reference": "30c19ed0f3264cb660ea496895cfb6ef7ee3653b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/firebase/php-jwt/zipball/500501c2ce893c824c801da135d02661199f60c5",
-                "reference": "500501c2ce893c824c801da135d02661199f60c5",
+                "url": "https://api.github.com/repos/firebase/php-jwt/zipball/30c19ed0f3264cb660ea496895cfb6ef7ee3653b",
+                "reference": "30c19ed0f3264cb660ea496895cfb6ef7ee3653b",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/firebase/php-jwt/issues",
-                "source": "https://github.com/firebase/php-jwt/tree/v6.10.1"
+                "source": "https://github.com/firebase/php-jwt/tree/v6.10.2"
             },
-            "time": "2024-05-18T18:05:11+00:00"
+            "time": "2024-11-24T11:22:49+00:00"
         },
         {
             "name": "fruitcake/php-cors",
         },
         {
             "name": "laravel/framework",
-            "version": "v10.48.22",
+            "version": "v10.48.25",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/framework.git",
-                "reference": "c4ea52bb044faef4a103d7dd81746c01b2ec860e"
+                "reference": "f132b23b13909cc22c615c01b0c5640541c3da0c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/framework/zipball/c4ea52bb044faef4a103d7dd81746c01b2ec860e",
-                "reference": "c4ea52bb044faef4a103d7dd81746c01b2ec860e",
+                "url": "https://api.github.com/repos/laravel/framework/zipball/f132b23b13909cc22c615c01b0c5640541c3da0c",
+                "reference": "f132b23b13909cc22c615c01b0c5640541c3da0c",
                 "shasum": ""
             },
             "require": {
                 "nyholm/psr7": "^1.2",
                 "orchestra/testbench-core": "^8.23.4",
                 "pda/pheanstalk": "^4.0",
-                "phpstan/phpstan": "^1.4.7",
+                "phpstan/phpstan": "~1.11.11",
                 "phpunit/phpunit": "^10.0.7",
                 "predis/predis": "^2.0.2",
                 "symfony/cache": "^6.2",
                 "issues": "https://github.com/laravel/framework/issues",
                 "source": "https://github.com/laravel/framework"
             },
-            "time": "2024-09-12T15:00:09+00:00"
+            "time": "2024-11-26T15:32:57+00:00"
         },
         {
             "name": "laravel/prompts",
         },
         {
             "name": "laravel/serializable-closure",
-            "version": "v1.3.5",
+            "version": "v1.3.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/serializable-closure.git",
-                "reference": "1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c"
+                "reference": "4f48ade902b94323ca3be7646db16209ec76be3d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c",
-                "reference": "1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c",
+                "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/4f48ade902b94323ca3be7646db16209ec76be3d",
+                "reference": "4f48ade902b94323ca3be7646db16209ec76be3d",
                 "shasum": ""
             },
             "require": {
                 "issues": "https://github.com/laravel/serializable-closure/issues",
                 "source": "https://github.com/laravel/serializable-closure"
             },
-            "time": "2024-09-23T13:33:08+00:00"
+            "time": "2024-11-14T18:34:49+00:00"
         },
         {
             "name": "laravel/socialite",
         },
         {
             "name": "monolog/monolog",
-            "version": "3.7.0",
+            "version": "3.8.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Seldaek/monolog.git",
-                "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8"
+                "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/f4393b648b78a5408747de94fca38beb5f7e9ef8",
-                "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8",
+                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/32e515fdc02cdafbe4593e30a9350d486b125b67",
+                "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67",
                 "shasum": ""
             },
             "require": {
                 "guzzlehttp/psr7": "^2.2",
                 "mongodb/mongodb": "^1.8",
                 "php-amqplib/php-amqplib": "~2.4 || ^3",
-                "phpstan/phpstan": "^1.9",
-                "phpstan/phpstan-deprecation-rules": "^1.0",
-                "phpstan/phpstan-strict-rules": "^1.4",
-                "phpunit/phpunit": "^10.5.17",
+                "php-console/php-console": "^3.1.8",
+                "phpstan/phpstan": "^2",
+                "phpstan/phpstan-deprecation-rules": "^2",
+                "phpstan/phpstan-strict-rules": "^2",
+                "phpunit/phpunit": "^10.5.17 || ^11.0.7",
                 "predis/predis": "^1.1 || ^2",
-                "ruflin/elastica": "^7",
+                "rollbar/rollbar": "^4.0",
+                "ruflin/elastica": "^7 || ^8",
                 "symfony/mailer": "^5.4 || ^6",
                 "symfony/mime": "^5.4 || ^6"
             },
             ],
             "support": {
                 "issues": "https://github.com/Seldaek/monolog/issues",
-                "source": "https://github.com/Seldaek/monolog/tree/3.7.0"
+                "source": "https://github.com/Seldaek/monolog/tree/3.8.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-06-28T09:40:51+00:00"
+            "time": "2024-11-12T13:57:08+00:00"
         },
         {
             "name": "mtdowling/jmespath.php",
         },
         {
             "name": "nunomaduro/termwind",
-            "version": "v1.16.0",
+            "version": "v1.17.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nunomaduro/termwind.git",
-                "reference": "dcf1ec3dfa36137b7ce41d43866644a7ab8fc257"
+                "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dcf1ec3dfa36137b7ce41d43866644a7ab8fc257",
-                "reference": "dcf1ec3dfa36137b7ce41d43866644a7ab8fc257",
+                "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/5369ef84d8142c1d87e4ec278711d4ece3cbf301",
+                "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301",
                 "shasum": ""
             },
             "require": {
                 "ext-mbstring": "*",
                 "php": "^8.1",
-                "symfony/console": "^6.4.12"
+                "symfony/console": "^6.4.15"
             },
             "require-dev": {
-                "illuminate/console": "^10.48.22",
-                "illuminate/support": "^10.48.22",
-                "laravel/pint": "^1.18.1",
-                "pestphp/pest": "^2",
+                "illuminate/console": "^10.48.24",
+                "illuminate/support": "^10.48.24",
+                "laravel/pint": "^1.18.2",
+                "pestphp/pest": "^2.36.0",
                 "pestphp/pest-plugin-mock": "2.0.0",
-                "phpstan/phpstan": "^1.12.6",
+                "phpstan/phpstan": "^1.12.11",
                 "phpstan/phpstan-strict-rules": "^1.6.1",
-                "symfony/var-dumper": "^6.4.11",
+                "symfony/var-dumper": "^6.4.15",
                 "thecodingmachine/phpstan-strict-rules": "^1.0.0"
             },
             "type": "library",
             ],
             "support": {
                 "issues": "https://github.com/nunomaduro/termwind/issues",
-                "source": "https://github.com/nunomaduro/termwind/tree/v1.16.0"
+                "source": "https://github.com/nunomaduro/termwind/tree/v1.17.0"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2024-10-15T15:27:12+00:00"
+            "time": "2024-11-21T10:36:35+00:00"
         },
         {
             "name": "onelogin/php-saml",
         },
         {
             "name": "predis/predis",
-            "version": "v2.2.2",
+            "version": "v2.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/predis/predis.git",
-                "reference": "b1d3255ed9ad4d7254f9f9bba386c99f4bb983d1"
+                "reference": "bac46bfdb78cd6e9c7926c697012aae740cb9ec9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/predis/predis/zipball/b1d3255ed9ad4d7254f9f9bba386c99f4bb983d1",
-                "reference": "b1d3255ed9ad4d7254f9f9bba386c99f4bb983d1",
+                "url": "https://api.github.com/repos/predis/predis/zipball/bac46bfdb78cd6e9c7926c697012aae740cb9ec9",
+                "reference": "bac46bfdb78cd6e9c7926c697012aae740cb9ec9",
                 "shasum": ""
             },
             "require": {
             "require-dev": {
                 "friendsofphp/php-cs-fixer": "^3.3",
                 "phpstan/phpstan": "^1.9",
-                "phpunit/phpunit": "^8.0 || ~9.4.4"
+                "phpunit/phpunit": "^8.0 || ^9.4"
             },
             "suggest": {
                 "ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
             ],
             "support": {
                 "issues": "https://github.com/predis/predis/issues",
-                "source": "https://github.com/predis/predis/tree/v2.2.2"
+                "source": "https://github.com/predis/predis/tree/v2.3.0"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2023-09-13T16:42:03+00:00"
+            "time": "2024-11-21T20:00:02+00:00"
         },
         {
             "name": "psr/cache",
         },
         {
             "name": "robrichards/xmlseclibs",
-            "version": "3.1.1",
+            "version": "3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/robrichards/xmlseclibs.git",
-                "reference": "f8f19e58f26cdb42c54b214ff8a820760292f8df"
+                "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/f8f19e58f26cdb42c54b214ff8a820760292f8df",
-                "reference": "f8f19e58f26cdb42c54b214ff8a820760292f8df",
+                "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/2bdfd742624d739dfadbd415f00181b4a77aaf07",
+                "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/robrichards/xmlseclibs/issues",
-                "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.1"
+                "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.3"
             },
-            "time": "2020-09-05T13:00:25+00:00"
+            "time": "2024-11-20T21:13:56+00:00"
         },
         {
             "name": "sabberworm/php-css-parser",
         },
         {
             "name": "socialiteproviders/manager",
-            "version": "v4.6.0",
+            "version": "v4.7.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/SocialiteProviders/Manager.git",
-                "reference": "dea5190981c31b89e52259da9ab1ca4e2b258b21"
+                "reference": "ab0691b82cec77efd90154c78f1854903455c82f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/dea5190981c31b89e52259da9ab1ca4e2b258b21",
-                "reference": "dea5190981c31b89e52259da9ab1ca4e2b258b21",
+                "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/ab0691b82cec77efd90154c78f1854903455c82f",
+                "reference": "ab0691b82cec77efd90154c78f1854903455c82f",
                 "shasum": ""
             },
             "require": {
                 "issues": "https://github.com/socialiteproviders/manager/issues",
                 "source": "https://github.com/socialiteproviders/manager"
             },
-            "time": "2024-05-04T07:57:39+00:00"
+            "time": "2024-11-10T01:56:18+00:00"
         },
         {
             "name": "socialiteproviders/microsoft-azure",
         },
         {
             "name": "symfony/console",
-            "version": "v6.4.14",
+            "version": "v6.4.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "897c2441ed4eec8a8a2c37b943427d24dba3f26b"
+                "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/897c2441ed4eec8a8a2c37b943427d24dba3f26b",
-                "reference": "897c2441ed4eec8a8a2c37b943427d24dba3f26b",
+                "url": "https://api.github.com/repos/symfony/console/zipball/f1fc6f47283e27336e7cebb9e8946c8de7bff9bd",
+                "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd",
                 "shasum": ""
             },
             "require": {
                 "terminal"
             ],
             "support": {
-                "source": "https://github.com/symfony/console/tree/v6.4.14"
+                "source": "https://github.com/symfony/console/tree/v6.4.15"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-11-05T15:34:40+00:00"
+            "time": "2024-11-06T14:19:14+00:00"
         },
         {
             "name": "symfony/css-selector",
         },
         {
             "name": "symfony/deprecation-contracts",
-            "version": "v3.5.0",
+            "version": "v3.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/deprecation-contracts.git",
-                "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1"
+                "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1",
-                "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1",
+                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
+                "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
                 "shasum": ""
             },
             "require": {
             "description": "A generic function and convention to trigger deprecation notices",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0"
+                "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-04-18T09:32:20+00:00"
+            "time": "2024-09-25T14:20:29+00:00"
         },
         {
             "name": "symfony/error-handler",
         },
         {
             "name": "symfony/event-dispatcher-contracts",
-            "version": "v3.5.0",
+            "version": "v3.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher-contracts.git",
-                "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50"
+                "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/8f93aec25d41b72493c6ddff14e916177c9efc50",
-                "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f",
+                "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f",
                 "shasum": ""
             },
             "require": {
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.0"
+                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-04-18T09:32:20+00:00"
+            "time": "2024-09-25T14:20:29+00:00"
         },
         {
             "name": "symfony/finder",
         },
         {
             "name": "symfony/http-foundation",
-            "version": "v6.4.14",
+            "version": "v6.4.16",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-foundation.git",
-                "reference": "ba020a321a95519303a3f09ec2824d34d601c388"
+                "reference": "431771b7a6f662f1575b3cfc8fd7617aa9864d57"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ba020a321a95519303a3f09ec2824d34d601c388",
-                "reference": "ba020a321a95519303a3f09ec2824d34d601c388",
+                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/431771b7a6f662f1575b3cfc8fd7617aa9864d57",
+                "reference": "431771b7a6f662f1575b3cfc8fd7617aa9864d57",
                 "shasum": ""
             },
             "require": {
                 "symfony/polyfill-php83": "^1.27"
             },
             "conflict": {
-                "symfony/cache": "<6.3"
+                "symfony/cache": "<6.4.12|>=7.0,<7.1.5"
             },
             "require-dev": {
                 "doctrine/dbal": "^2.13.1|^3|^4",
                 "predis/predis": "^1.1|^2.0",
-                "symfony/cache": "^6.3|^7.0",
+                "symfony/cache": "^6.4.12|^7.1.5",
                 "symfony/dependency-injection": "^5.4|^6.0|^7.0",
                 "symfony/expression-language": "^5.4|^6.0|^7.0",
                 "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0",
             "description": "Defines an object-oriented layer for the HTTP specification",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-foundation/tree/v6.4.14"
+                "source": "https://github.com/symfony/http-foundation/tree/v6.4.16"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-11-05T16:39:55+00:00"
+            "time": "2024-11-13T18:58:10+00:00"
         },
         {
             "name": "symfony/http-kernel",
-            "version": "v6.4.14",
+            "version": "v6.4.16",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-kernel.git",
-                "reference": "8278a947d0369754a47b758a9e17b72cab970951"
+                "reference": "8838b5b21d807923b893ccbfc2cbeda0f1bc00f0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/8278a947d0369754a47b758a9e17b72cab970951",
-                "reference": "8278a947d0369754a47b758a9e17b72cab970951",
+                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/8838b5b21d807923b893ccbfc2cbeda0f1bc00f0",
+                "reference": "8838b5b21d807923b893ccbfc2cbeda0f1bc00f0",
                 "shasum": ""
             },
             "require": {
             "description": "Provides a structured process for converting a Request into a Response",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-kernel/tree/v6.4.14"
+                "source": "https://github.com/symfony/http-kernel/tree/v6.4.16"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-11-06T09:45:21+00:00"
+            "time": "2024-11-27T12:49:36+00:00"
         },
         {
             "name": "symfony/mime",
         },
         {
             "name": "symfony/process",
-            "version": "v6.4.14",
+            "version": "v6.4.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/process.git",
-                "reference": "25214adbb0996d18112548de20c281be9f27279f"
+                "reference": "3cb242f059c14ae08591c5c4087d1fe443564392"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/25214adbb0996d18112548de20c281be9f27279f",
-                "reference": "25214adbb0996d18112548de20c281be9f27279f",
+                "url": "https://api.github.com/repos/symfony/process/zipball/3cb242f059c14ae08591c5c4087d1fe443564392",
+                "reference": "3cb242f059c14ae08591c5c4087d1fe443564392",
                 "shasum": ""
             },
             "require": {
             "description": "Executes commands in sub-processes",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/process/tree/v6.4.14"
+                "source": "https://github.com/symfony/process/tree/v6.4.15"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-11-06T09:25:01+00:00"
+            "time": "2024-11-06T14:19:14+00:00"
         },
         {
             "name": "symfony/routing",
-            "version": "v6.4.13",
+            "version": "v6.4.16",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/routing.git",
-                "reference": "640a74250d13f9c30d5ca045b6aaaabcc8215278"
+                "reference": "91e02e606b4b705c2f4fb42f7e7708b7923a3220"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/routing/zipball/640a74250d13f9c30d5ca045b6aaaabcc8215278",
-                "reference": "640a74250d13f9c30d5ca045b6aaaabcc8215278",
+                "url": "https://api.github.com/repos/symfony/routing/zipball/91e02e606b4b705c2f4fb42f7e7708b7923a3220",
+                "reference": "91e02e606b4b705c2f4fb42f7e7708b7923a3220",
                 "shasum": ""
             },
             "require": {
                 "url"
             ],
             "support": {
-                "source": "https://github.com/symfony/routing/tree/v6.4.13"
+                "source": "https://github.com/symfony/routing/tree/v6.4.16"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-10-01T08:30:56+00:00"
+            "time": "2024-11-13T15:31:34+00:00"
         },
         {
             "name": "symfony/service-contracts",
-            "version": "v3.5.0",
+            "version": "v3.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/service-contracts.git",
-                "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f"
+                "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/bd1d9e59a81d8fa4acdcea3f617c581f7475a80f",
-                "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f",
+                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0",
+                "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0",
                 "shasum": ""
             },
             "require": {
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/service-contracts/tree/v3.5.0"
+                "source": "https://github.com/symfony/service-contracts/tree/v3.5.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-04-18T09:32:20+00:00"
+            "time": "2024-09-25T14:20:29+00:00"
         },
         {
             "name": "symfony/string",
-            "version": "v6.4.13",
+            "version": "v6.4.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/string.git",
-                "reference": "38371c60c71c72b3d64d8d76f6b1bb81a2cc3627"
+                "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/string/zipball/38371c60c71c72b3d64d8d76f6b1bb81a2cc3627",
-                "reference": "38371c60c71c72b3d64d8d76f6b1bb81a2cc3627",
+                "url": "https://api.github.com/repos/symfony/string/zipball/73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f",
+                "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f",
                 "shasum": ""
             },
             "require": {
                 "utf8"
             ],
             "support": {
-                "source": "https://github.com/symfony/string/tree/v6.4.13"
+                "source": "https://github.com/symfony/string/tree/v6.4.15"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-25T14:18:03+00:00"
+            "time": "2024-11-13T13:31:12+00:00"
         },
         {
             "name": "symfony/translation",
         },
         {
             "name": "symfony/translation-contracts",
-            "version": "v3.5.0",
+            "version": "v3.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation-contracts.git",
-                "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a"
+                "reference": "4667ff3bd513750603a09c8dedbea942487fb07c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/b9d2189887bb6b2e0367a9fc7136c5239ab9b05a",
-                "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a",
+                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/4667ff3bd513750603a09c8dedbea942487fb07c",
+                "reference": "4667ff3bd513750603a09c8dedbea942487fb07c",
                 "shasum": ""
             },
             "require": {
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/translation-contracts/tree/v3.5.0"
+                "source": "https://github.com/symfony/translation-contracts/tree/v3.5.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-04-18T09:32:20+00:00"
+            "time": "2024-09-25T14:20:29+00:00"
         },
         {
             "name": "symfony/uid",
         },
         {
             "name": "symfony/var-dumper",
-            "version": "v6.4.14",
+            "version": "v6.4.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/var-dumper.git",
-                "reference": "93c09246038178717a9c14b809ea8151ffcf7091"
+                "reference": "38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/93c09246038178717a9c14b809ea8151ffcf7091",
-                "reference": "93c09246038178717a9c14b809ea8151ffcf7091",
+                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80",
+                "reference": "38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80",
                 "shasum": ""
             },
             "require": {
                 "dump"
             ],
             "support": {
-                "source": "https://github.com/symfony/var-dumper/tree/v6.4.14"
+                "source": "https://github.com/symfony/var-dumper/tree/v6.4.15"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-11-05T15:34:40+00:00"
+            "time": "2024-11-08T15:28:48+00:00"
         },
         {
             "name": "tijsverkoyen/css-to-inline-styles",
         },
         {
             "name": "voku/portable-ascii",
-            "version": "2.0.1",
+            "version": "2.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/voku/portable-ascii.git",
-                "reference": "b56450eed252f6801410d810c8e1727224ae0743"
+                "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b56450eed252f6801410d810c8e1727224ae0743",
-                "reference": "b56450eed252f6801410d810c8e1727224ae0743",
+                "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
+                "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
                 "shasum": ""
             },
             "require": {
             "authors": [
                 {
                     "name": "Lars Moelleken",
-                    "homepage": "http://www.moelleken.org/"
+                    "homepage": "https://www.moelleken.org/"
                 }
             ],
             "description": "Portable ASCII library - performance optimized (ascii) string functions for php.",
             ],
             "support": {
                 "issues": "https://github.com/voku/portable-ascii/issues",
-                "source": "https://github.com/voku/portable-ascii/tree/2.0.1"
+                "source": "https://github.com/voku/portable-ascii/tree/2.0.3"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-03-08T17:03:00+00:00"
+            "time": "2024-11-21T01:49:47+00:00"
         },
         {
             "name": "webmozart/assert",
     "packages-dev": [
         {
             "name": "fakerphp/faker",
-            "version": "v1.23.1",
+            "version": "v1.24.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/FakerPHP/Faker.git",
-                "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b"
+                "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/bfb4fe148adbf78eff521199619b93a52ae3554b",
-                "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b",
+                "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
+                "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/FakerPHP/Faker/issues",
-                "source": "https://github.com/FakerPHP/Faker/tree/v1.23.1"
+                "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1"
             },
-            "time": "2024-01-02T13:46:09+00:00"
+            "time": "2024-11-21T13:46:39+00:00"
         },
         {
             "name": "filp/whoops",
         },
         {
             "name": "itsgoingd/clockwork",
-            "version": "v5.2.2",
+            "version": "v5.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/itsgoingd/clockwork.git",
-                "reference": "29bc4cedfbe742b419544c30b7b6e15cd9da08ef"
+                "reference": "7b0c40418df761f7a78e88762a323386a139d83d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/29bc4cedfbe742b419544c30b7b6e15cd9da08ef",
-                "reference": "29bc4cedfbe742b419544c30b7b6e15cd9da08ef",
+                "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/7b0c40418df761f7a78e88762a323386a139d83d",
+                "reference": "7b0c40418df761f7a78e88762a323386a139d83d",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
-                "php": ">=5.6"
+                "php": ">=7.1"
             },
             "suggest": {
                 "ext-pdo": "Needed in order to use a SQL database for metadata storage",
                 "ext-pdo_mysql": "Needed in order to use MySQL for metadata storage",
                 "ext-pdo_postgres": "Needed in order to use Postgres for metadata storage",
                 "ext-pdo_sqlite": "Needed in order to use a SQLite for metadata storage",
-                "ext-redis": "Needed in order to use Redis for metadata storage"
+                "ext-redis": "Needed in order to use Redis for metadata storage",
+                "php-http/discovery": "Vanilla integration - required for the middleware zero-configuration setup"
             },
             "type": "library",
             "extra": {
             ],
             "support": {
                 "issues": "https://github.com/itsgoingd/clockwork/issues",
-                "source": "https://github.com/itsgoingd/clockwork/tree/v5.2.2"
+                "source": "https://github.com/itsgoingd/clockwork/tree/v5.3.1"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2024-04-14T10:49:22+00:00"
+            "time": "2024-11-19T17:25:22+00:00"
         },
         {
             "name": "larastan/larastan",
-            "version": "v2.9.10",
+            "version": "v2.9.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/larastan/larastan.git",
-                "reference": "9e7233d88f4f10796fadb8a2d85c5f2b55277c76"
+                "reference": "19012b39fbe4dede43dbe0c126d9681827a5e908"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/larastan/larastan/zipball/9e7233d88f4f10796fadb8a2d85c5f2b55277c76",
-                "reference": "9e7233d88f4f10796fadb8a2d85c5f2b55277c76",
+                "url": "https://api.github.com/repos/larastan/larastan/zipball/19012b39fbe4dede43dbe0c126d9681827a5e908",
+                "reference": "19012b39fbe4dede43dbe0c126d9681827a5e908",
                 "shasum": ""
             },
             "require": {
                 "illuminate/support": "^9.52.16 || ^10.28.0 || ^11.16",
                 "php": "^8.0.2",
                 "phpmyadmin/sql-parser": "^5.9.0",
-                "phpstan/phpstan": "^1.12.5"
+                "phpstan/phpstan": "^1.12.11"
             },
             "require-dev": {
                 "doctrine/coding-standard": "^12.0",
+                "laravel/framework": "^9.52.16 || ^10.28.0 || ^11.16",
+                "mockery/mockery": "^1.5.1",
                 "nikic/php-parser": "^4.19.1",
                 "orchestra/canvas": "^7.11.1 || ^8.11.0 || ^9.0.2",
-                "orchestra/testbench": "^7.33.0 || ^8.13.0 || ^9.0.3",
+                "orchestra/testbench-core": "^7.33.0 || ^8.13.0 || ^9.0.9",
                 "phpstan/phpstan-deprecation-rules": "^1.2",
                 "phpunit/phpunit": "^9.6.13 || ^10.5.16"
             },
             },
             "type": "phpstan-extension",
             "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0-dev"
-                },
                 "phpstan": {
                     "includes": [
                         "extension.neon"
                     ]
+                },
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
                 }
             },
             "autoload": {
                     "email": "[email protected]"
                 }
             ],
-            "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan wrapper for Laravel",
+            "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
             "keywords": [
                 "PHPStan",
                 "code analyse",
             ],
             "support": {
                 "issues": "https://github.com/larastan/larastan/issues",
-                "source": "https://github.com/larastan/larastan/tree/v2.9.10"
+                "source": "https://github.com/larastan/larastan/tree/v2.9.12"
             },
             "funding": [
-                {
-                    "url": "https://www.paypal.com/paypalme/enunomaduro",
-                    "type": "custom"
-                },
                 {
                     "url": "https://github.com/canvural",
                     "type": "github"
-                },
-                {
-                    "url": "https://github.com/nunomaduro",
-                    "type": "github"
-                },
-                {
-                    "url": "https://www.patreon.com/nunomaduro",
-                    "type": "patreon"
                 }
             ],
-            "time": "2024-10-19T23:04:40+00:00"
+            "time": "2024-11-26T23:09:02+00:00"
         },
         {
             "name": "mockery/mockery",
         },
         {
             "name": "myclabs/deep-copy",
-            "version": "1.12.0",
+            "version": "1.12.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/myclabs/DeepCopy.git",
-                "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c"
+                "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c",
-                "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845",
+                "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/myclabs/DeepCopy/issues",
-                "source": "https://github.com/myclabs/DeepCopy/tree/1.12.0"
+                "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-06-12T14:39:25+00:00"
+            "time": "2024-11-08T17:47:46+00:00"
         },
         {
             "name": "nunomaduro/collision",
         },
         {
             "name": "phpmyadmin/sql-parser",
-            "version": "5.10.0",
+            "version": "5.10.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpmyadmin/sql-parser.git",
-                "reference": "91d980ab76c3f152481e367f62b921adc38af451"
+                "reference": "b14fd66496a22d8dd7f7e2791edd9e8674422f17"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/91d980ab76c3f152481e367f62b921adc38af451",
-                "reference": "91d980ab76c3f152481e367f62b921adc38af451",
+                "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/b14fd66496a22d8dd7f7e2791edd9e8674422f17",
+                "reference": "b14fd66496a22d8dd7f7e2791edd9e8674422f17",
                 "shasum": ""
             },
             "require": {
                     "type": "other"
                 }
             ],
-            "time": "2024-08-29T20:56:34+00:00"
+            "time": "2024-11-10T04:10:31+00:00"
         },
         {
             "name": "phpstan/phpstan",
-            "version": "1.12.8",
+            "version": "1.12.11",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpstan/phpstan.git",
-                "reference": "f6a60a4d66142b8156c9da923f1972657bc4748c"
+                "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f6a60a4d66142b8156c9da923f1972657bc4748c",
-                "reference": "f6a60a4d66142b8156c9da923f1972657bc4748c",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0d1fc20a962a91be578bcfe7cf939e6e1a2ff733",
+                "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733",
                 "shasum": ""
             },
             "require": {
                     "type": "github"
                 }
             ],
-            "time": "2024-11-06T19:06:49+00:00"
+            "time": "2024-11-17T14:08:01+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
         },
         {
             "name": "squizlabs/php_codesniffer",
-            "version": "3.10.3",
+            "version": "3.11.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
-                "reference": "62d32998e820bddc40f99f8251958aed187a5c9c"
+                "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/62d32998e820bddc40f99f8251958aed187a5c9c",
-                "reference": "62d32998e820bddc40f99f8251958aed187a5c9c",
+                "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/19473c30efe4f7b3cd42522d0b2e6e7f243c6f87",
+                "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87",
                 "shasum": ""
             },
             "require": {
                     "type": "open_collective"
                 }
             ],
-            "time": "2024-09-18T10:38:58+00:00"
+            "time": "2024-11-16T12:02:36+00:00"
         },
         {
             "name": "ssddanbrown/asserthtml",
         },
         {
             "name": "symfony/dom-crawler",
-            "version": "v6.4.13",
+            "version": "v6.4.16",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dom-crawler.git",
-                "reference": "ae074dffb018c37a57071990d16e6152728dd972"
+                "reference": "4304e6ad5c894a9c72831ad459f627bfd35d766d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/ae074dffb018c37a57071990d16e6152728dd972",
-                "reference": "ae074dffb018c37a57071990d16e6152728dd972",
+                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/4304e6ad5c894a9c72831ad459f627bfd35d766d",
+                "reference": "4304e6ad5c894a9c72831ad459f627bfd35d766d",
                 "shasum": ""
             },
             "require": {
             "description": "Eases DOM navigation for HTML and XML documents",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/dom-crawler/tree/v6.4.13"
+                "source": "https://github.com/symfony/dom-crawler/tree/v6.4.16"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-10-25T15:07:50+00:00"
+            "time": "2024-11-13T15:06:22+00:00"
         },
         {
             "name": "theseer/tokenizer",
diff --git a/database/factories/Exports/ImportFactory.php b/database/factories/Exports/ImportFactory.php
new file mode 100644 (file)
index 0000000..5d0b4f8
--- /dev/null
@@ -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(),
+        ];
+    }
+}
index 21f45aa0691b28e147dba9c85eed20842acf3ab4..99416f9fcf62add4899a0a6f38cca59045029fd3 100644 (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();
     }
 };
diff --git a/database/migrations/2024_10_29_114420_add_import_role_permission.php b/database/migrations/2024_10_29_114420_add_import_role_permission.php
new file mode 100644 (file)
index 0000000..17bbe4c
--- /dev/null
@@ -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();
+    }
+};
diff --git a/database/migrations/2024_11_02_160700_create_imports_table.php b/database/migrations/2024_11_02_160700_create_imports_table.php
new file mode 100644 (file)
index 0000000..0784591
--- /dev/null
@@ -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');
+    }
+};
diff --git a/database/migrations/2024_11_27_171039_add_instance_id_setting.php b/database/migrations/2024_11_27_171039_add_instance_id_setting.php
new file mode 100644 (file)
index 0000000..ee1e90d
--- /dev/null
@@ -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();
+    }
+};
index fea8c01e353887d2bb169d330fec1bc51b997a0a..cd8bf279f28db0858cbd3c238ea029d4582d107d 100644 (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'),
index 50d528fafb8d1104135100629fa37826805e378e..edab90ca1c5e9ad1ad126dcb217700844eb230ee 100644 (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
index e91d34a713377a7e579a6594000c01fb5c98ba57..b09edda8863e8eb02f8b695c168138719be23ee2 100755 (executable)
@@ -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
index 3c7a6e9d299f4b76095538111231c263fc5eb617..0324140f8bd4b87ef92c133721bda75126f032a7 100644 (file)
@@ -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 `[email protected]` and `password` as password on `localhost:8080` (or another port if specified).
 
diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md
new file mode 100644 (file)
index 0000000..754cb4d
--- /dev/null
@@ -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).
\ No newline at end of file
index 8507cb4cb61f926ab27adcb48ef0932b6375320a..747ccc986d75580778530de8c0ea132bf1cbb00c 100644 (file)
@@ -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
 -----------
index d0bf9cf4577ca2c96804bcf010c18975f515eaf9..412bf9d3be330fa4badf86472254e45897cfb6fa 100644 (file)
@@ -46,7 +46,7 @@ return [
     'bookshelf_create_from_book_notification'    => 'Kniha byla úspěšně převedena na polici',
     'bookshelf_update'                 => 'aktualizovat polici',
     'bookshelf_update_notification'    => 'Police byla úspěšně aktualizována',
-    'bookshelf_delete'                 => 'odstranit knihovnu',
+    'bookshelf_delete'                 => 'odstranil polici',
     'bookshelf_delete_notification'    => 'Police byla úspěšně odstraněna',
 
     // Revisions
index 6122fe6a445827fde175c72eba5b4a6993cb4bfe..77fb8a3fb95693bd19283df7cec553ad34d4de4b 100644 (file)
@@ -83,8 +83,8 @@ return [
     'search_update' => 'Aktualizovat hledání',
 
     // Shelves
-    'shelf' => 'Knihovna',
-    'shelves' => 'Knihovny',
+    'shelf' => 'Police',
+    'shelves' => 'Police',
     'x_shelves' => '{0}:count polic|{1}:count police|[2,4]:count police|[5,*]:count polic',
     'shelves_empty' => 'Nebyly vytvořeny žádné police',
     'shelves_create' => 'Vytvořit novou polici',
index 6a99fec6f92831a3889084b5b4972bceada3af36..e92d1c165a53b121409332898a38b41b2bacb509 100644 (file)
@@ -63,17 +63,17 @@ return [
 
     // Auth
     'auth_login' => 'loggede ind',
-    'auth_register' => 'registered as new user',
+    'auth_register' => 'registreret som ny bruger',
     'auth_password_reset_request' => 'requested user password reset',
     'auth_password_reset_update' => 'nulstil adgangskode',
-    'mfa_setup_method' => 'configured MFA method',
+    'mfa_setup_method' => 'konfigureret MFA metode',
     'mfa_setup_method_notification' => 'Multi-faktor metode konfigureret',
-    'mfa_remove_method' => 'removed MFA method',
+    'mfa_remove_method' => 'fjernet MFA metode',
     'mfa_remove_method_notification' => 'Multi-faktor metode fjernet',
 
     // Settings
-    'settings_update' => 'updated settings',
-    'settings_update_notification' => 'Settings successfully updated',
+    'settings_update' => 'opdaterede indstillinger',
+    'settings_update_notification' => 'Indstillinger opdateret',
     'maintenance_action_run' => 'ran maintenance action',
 
     // Webhooks
@@ -87,13 +87,13 @@ return [
     // Users
     'user_create' => 'opret bruger',
     'user_create_notification' => 'Bruger oprettet korrekt',
-    'user_update' => 'updated user',
+    'user_update' => 'opdateret bruger',
     'user_update_notification' => 'Brugeren blev opdateret',
-    'user_delete' => 'deleted user',
+    'user_delete' => 'slettet bruger',
     'user_delete_notification' => 'Brugeren blev fjernet',
 
     // API Tokens
-    'api_token_create' => 'created API token',
+    'api_token_create' => 'oprettet API token',
     'api_token_create_notification' => 'API token successfully created',
     'api_token_update' => 'updated API token',
     'api_token_update_notification' => 'API token successfully updated',
@@ -101,12 +101,12 @@ return [
     'api_token_delete_notification' => 'API token successfully deleted',
 
     // Roles
-    'role_create' => 'created role',
-    'role_create_notification' => 'Role successfully created',
-    'role_update' => 'updated role',
-    'role_update_notification' => 'Role successfully updated',
-    'role_delete' => 'deleted role',
-    'role_delete_notification' => 'Role successfully deleted',
+    'role_create' => 'oprettet rolle',
+    'role_create_notification' => 'Rolle oprettet',
+    'role_update' => 'opdateret rolle',
+    'role_update_notification' => 'Rolle opdateret',
+    'role_delete' => 'slettet rolle',
+    'role_delete_notification' => 'Rollen blev slettet',
 
     // Recycle Bin
     'recycle_bin_empty' => 'emptied recycle bin',
index 5fa2d156f1edbc094f82f5180e04a13537a0d1d2..fa802af16f0878fa0c27f69aa2f1de7f92552f52 100644 (file)
@@ -33,7 +33,7 @@ return [
     'app_custom_html_disabled_notice' => 'Brugerdefineret HTML head indhold er deaktiveret på denne indstillingsside for at, at ændringer kan rulles tilbage.',
     'app_logo' => 'Applikationslogo',
     'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
-    'app_icon' => 'Application Icon',
+    'app_icon' => 'Program ikon',
     'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.',
     'app_homepage' => 'Applikationsforside',
     'app_homepage_desc' => 'Vælg en visning, der skal vises på forsiden i stedet for standardvisningen. Sidetilladelser ignoreres for de valgte sider.',
index 092398ef0e1475089fa71b7e60aa1f49fc056013..7c3454d41ca287406316a6eb3f528250c5361c3d 100644 (file)
@@ -84,6 +84,14 @@ return [
     'webhook_delete' => 'deleted webhook',
     'webhook_delete_notification' => 'Webhook successfully deleted',
 
+    // 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',
index 35e6f050bb885cc28cb83e5064fed910778c1ae1..26a563a7eb534388afd1e89fce92ea91e1a8a5c7 100644 (file)
@@ -39,9 +39,30 @@ return [
     'export_pdf' => 'PDF File',
     'export_text' => 'Plain Text File',
     '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' => 'Permissions',
index 9c40aa9ed339578a1585ddefda68760cd2563c8e..9d738379648d26fd0b59ea0a0c82f53edf1e4b38 100644 (file)
@@ -105,6 +105,18 @@ return [
     'app_down' => ':appName is down right now',
     'back_soon' => 'It will be back up 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' => 'No authorization token found on the request',
     'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect',
index 5427cb9419ea1ff716704e5b06630b9afcd2c3b2..c0b6b692a57b9b5eaf7ce17d51a0baf0ae6380ab 100644 (file)
@@ -162,6 +162,7 @@ return [
     'role_access_api' => 'Access system API',
     'role_manage_settings' => 'Manage app settings',
     'role_export_content' => 'Export content',
+    'role_import_content' => 'Import content',
     'role_editor_change' => 'Change page editor',
     'role_notifications' => 'Receive & manage notifications',
     'role_asset' => 'Asset Permissions',
index 2a676c7c4cce0130591a929cefd8a21e7765be1c..d9b982d1e23e2eb2ad98ae4815e8b94de20f11dc 100644 (file)
@@ -105,6 +105,11 @@ return [
     'url'                  => 'The :attribute format is invalid.',
     'uploaded'             => 'The file could not be uploaded. The server may not accept files of this size.',
 
+    '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' => [
index 092398ef0e1475089fa71b7e60aa1f49fc056013..6c7af4429f0b8e94fca7aaa5f8f282cb79bc661a 100644 (file)
@@ -69,7 +69,7 @@ return [
     'mfa_setup_method' => 'configured MFA method',
     'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
     'mfa_remove_method' => 'removed MFA method',
-    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+    'mfa_remove_method_notification' => 'მულტი-ფაქტორული მეთოდი წარმატებით მოიხსნა',
 
     // Settings
     'settings_update' => 'updated settings',
index 36b7d847b4465c6a5afd132fe4bd7b002863896c..73182e0ba843ea8b2a7b9b2110d0be0c41124a89 100644 (file)
@@ -93,11 +93,11 @@ return [
     'user_delete_notification' => 'Brukaren vart fjerna',
 
     // API Tokens
-    'api_token_create' => 'created API token',
+    'api_token_create' => 'opprett API-nøkkel',
     'api_token_create_notification' => 'API-token er oppretta',
-    'api_token_update' => 'updated API token',
+    'api_token_update' => 'oppdatert api token',
     'api_token_update_notification' => 'API-token oppdatert',
-    'api_token_delete' => 'deleted API token',
+    'api_token_delete' => 'sletta api token',
     'api_token_delete_notification' => 'API-token vart sletta',
 
     // Roles
index 04546da1dc46ce185451cbf3c9f5579f7c3e4c59..5da686eaa9e3d6737d445180de919f912bb57d90 100644 (file)
@@ -91,7 +91,7 @@ return [
     'mfa_option_totp_title' => 'Mobilapplikasjon',
     'mfa_option_totp_desc' => 'For å bruka fleirfaktorautentisering treng du ein mobilapplikasjon som støttar TOTP-teknologien, slik som Google Authenticator, Authy eller Microsoft Authenticator.',
     'mfa_option_backup_codes_title' => 'Tryggleikskodar',
-    '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_option_backup_codes_desc' => 'Genererer et sett med engangs sikkerhetskoder som du skal logge inn for å bekrefte identiteten din. Sørg for å lagre disse på et sikkert og sikkert sted.',
     'mfa_gen_confirm_and_enable' => 'Stadfest og aktiver',
     'mfa_gen_backup_codes_title' => 'Konfigurasjon av tryggleikskodar',
     'mfa_gen_backup_codes_desc' => 'Lagre lista under med kodar på ein trygg stad. Når du skal ha tilgang til systemet kan du bruka ein av desse som ein faktor under innlogging.',
index d384e3924473ff8a499c891bd6875a2c30b604de..aed4880f1242b4a32b7a263cc0b65e497ed11ef8 100644 (file)
@@ -109,5 +109,5 @@ return [
     'terms_of_service' => 'Умови використання',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Шукати :appName',
 ];
index 664c6ed1725ea84c135fb3775b8ff508a1c41af8..01549e7f36f6f461ec528ca252cac420ab622b58 100644 (file)
@@ -224,8 +224,8 @@ return [
     'pages_edit_switch_to_markdown_clean' => '(Очистити вміст)',
     'pages_edit_switch_to_markdown_stable' => '(Стабілізувати вміст)',
     'pages_edit_switch_to_wysiwyg' => 'Змінити редактор на WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg' => 'Перейти на новий WYSIWYG',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(В альфа-тестуванні)',
     'pages_edit_set_changelog' => 'Встановити журнал змін',
     'pages_edit_enter_changelog_desc' => 'Введіть короткий опис внесених вами змін',
     'pages_edit_enter_changelog' => 'Введіть список змін',
index 602df64f8f5bbdac49f076a0bfca1eb77b347c77..7698093c1d509ba3210c99986d47f008ccd990e3 100644 (file)
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'Ви не можете видалити єдиного адміністратора',
     'users_cannot_delete_guest' => 'Ви не можете видалити гостьового користувача',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'Не вдалося створити користувача, оскільки не вдалося надіслати електронний лист із запрошенням',
 
     // Roles
     'role_cannot_be_edited' => 'Цю роль не можна редагувати',
index 71ba5acadf8726e4bfc2ef3949f71f1aeb8d01ce..6651f13966c533f0bd870c6f56169a80dc6226a1 100644 (file)
@@ -103,7 +103,7 @@ return [
     'totp'                 => '提供的代碼無效或已過期。',
     'unique'               => ':attribute 已經被使用。',
     'url'                  => ':attribute 格式無效。',
-    'uploaded'             => '無法上傳文件, 服務器可能不接受此大小的文件。',
+    'uploaded'             => '無法上傳文檔案, 伺服器可能不接受此大小的檔案。',
 
     // Custom validation lines
     'custom' => [
diff --git a/resources/js/app.js b/resources/js/app.js
deleted file mode 100644 (file)
index 5f4902f..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-import {EventManager} from './services/events.ts';
-import {HttpManager} from './services/http.ts';
-import {Translator} from './services/translations.ts';
-import * as componentMap from './components';
-import {ComponentStore} from './services/components.ts';
-
-// eslint-disable-next-line no-underscore-dangle
-window.__DEV__ = false;
-
-// Url retrieval function
-window.baseUrl = function baseUrl(path) {
-    let targetPath = path;
-    let basePath = document.querySelector('meta[name="base-url"]').getAttribute('content');
-    if (basePath[basePath.length - 1] === '/') basePath = basePath.slice(0, basePath.length - 1);
-    if (targetPath[0] === '/') targetPath = targetPath.slice(1);
-    return `${basePath}/${targetPath}`;
-};
-
-window.importVersioned = function importVersioned(moduleName) {
-    const version = document.querySelector('link[href*="/dist/styles.css?version="]').href.split('?version=').pop();
-    const importPath = window.baseUrl(`dist/${moduleName}.js?version=${version}`);
-    return import(importPath);
-};
-
-// Set events, http & translation services on window
-window.$http = new HttpManager();
-window.$events = new EventManager();
-window.$trans = new Translator();
-
-// Load & initialise components
-window.$components = new ComponentStore();
-window.$components.register(componentMap);
-window.$components.init();
diff --git a/resources/js/app.ts b/resources/js/app.ts
new file mode 100644 (file)
index 0000000..141a50e
--- /dev/null
@@ -0,0 +1,23 @@
+import {EventManager} from './services/events';
+import {HttpManager} from './services/http';
+import {Translator} from './services/translations';
+import * as componentMap from './components/index';
+import {ComponentStore} from './services/components';
+import {baseUrl, importVersioned} from "./services/util";
+
+// eslint-disable-next-line no-underscore-dangle
+window.__DEV__ = false;
+
+// Make common important util functions global
+window.baseUrl = baseUrl;
+window.importVersioned = importVersioned;
+
+// Setup events, http & translation services
+window.$http = new HttpManager();
+window.$events = new EventManager();
+window.$trans = new Translator();
+
+// Load & initialise components
+window.$components = new ComponentStore();
+window.$components.register(componentMap);
+window.$components.init();
index 3213c4835aa45fd5bad209e34b4b5a9e6b44967c..e7de15ae5fa03bd63a720508638cb91663374712 100644 (file)
@@ -1,5 +1,5 @@
-import {onChildEvent} from '../services/dom';
-import {uniqueId} from '../services/util';
+import {onChildEvent} from '../services/dom.ts';
+import {uniqueId} from '../services/util.ts';
 import {Component} from './component';
 
 /**
index aa2801f19e666c52ee9f1d738b71478ec105552a..6ed3deedf4dd6ed9e16d7f77b9a5edaa54bded8a 100644 (file)
@@ -1,4 +1,4 @@
-import {onSelect} from '../services/dom';
+import {onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 export class AjaxDeleteRow extends Component {
index 583dde5724424defb44906504ab4973a2fb88af5..de1a6db43a7e83cad11b8d783fb3db9be9de01fd 100644 (file)
@@ -1,4 +1,4 @@
-import {onEnterPress, onSelect} from '../services/dom';
+import {onEnterPress, onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 /**
index f45b25e36d05f92d96d175e8b5207020bdcd4d14..2dc7313a880901014a08c2e2f9c7a163030ab2ea 100644 (file)
@@ -1,4 +1,4 @@
-import {showLoading} from '../services/dom';
+import {showLoading} from '../services/dom.ts';
 import {Component} from './component';
 
 export class Attachments extends Component {
index 2eede241c694dc323f53ff33070163de2a79cea4..0b828e71bd1ea35976f348d02fd1c31dca2c5de6 100644 (file)
@@ -1,7 +1,7 @@
-import {escapeHtml} from '../services/util';
-import {onChildEvent} from '../services/dom';
+import {escapeHtml} from '../services/util.ts';
+import {onChildEvent} from '../services/dom.ts';
 import {Component} from './component';
-import {KeyboardNavigationHandler} from '../services/keyboard-navigation';
+import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
 
 const ajaxCache = {};
 
index 2ba7d5d36b00dc912be8c3d1f25f1b5e464d2ffe..48557141f6f7d80e66c501bfe9655340694566f0 100644 (file)
@@ -1,6 +1,6 @@
 import Sortable, {MultiDrag} from 'sortablejs';
 import {Component} from './component';
-import {htmlToDom} from '../services/dom';
+import {htmlToDom} from '../services/dom.ts';
 
 // Auto sort control
 const sortOperations = {
index 7c6480a1af0b8a6c176e1f6dd8beca9ada8a5033..6b0707bdd0570b4acbae3d32a7cf23f2864232ba 100644 (file)
@@ -1,4 +1,4 @@
-import {slideUp, slideDown} from '../services/animations';
+import {slideUp, slideDown} from '../services/animations.ts';
 import {Component} from './component';
 
 export class ChapterContents extends Component {
index 091c3483f4d6bd1ecffabb7f52f3f0b17933abfa..12937d47293b31effb7e2766d68af08598834899 100644 (file)
@@ -1,4 +1,4 @@
-import {onChildEvent, onEnterPress, onSelect} from '../services/dom';
+import {onChildEvent, onEnterPress, onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 export class CodeEditor extends Component {
index 6f740ed7163204020fcbd6569393b14729d787c4..7b6fa79fb3bcef4ab8c13b59090711754efac7c9 100644 (file)
@@ -1,4 +1,4 @@
-import {slideDown, slideUp} from '../services/animations';
+import {slideDown, slideUp} from '../services/animations.ts';
 import {Component} from './component';
 
 /**
index 184618fccfad9928cca02f81f3450b61fbe21ebe..00f3cfed201c34016da201a0fca14afb6c07e37c 100644 (file)
@@ -1,4 +1,4 @@
-import {onSelect} from '../services/dom';
+import {onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 /**
index 2344619f5e9b794de3177c6c4acf8a5c7550b351..fcbabc022a7c21395272c092e175fb968d773ab2 100644 (file)
@@ -1,5 +1,5 @@
-import {debounce} from '../services/util';
-import {transitionHeight} from '../services/animations';
+import {debounce} from '../services/util.ts';
+import {transitionHeight} from '../services/animations.ts';
 import {Component} from './component';
 
 export class DropdownSearch extends Component {
index 4efd428acf72b408dc4867e94a203173ed06c049..5dd5dd93b013023ebf466ef021e9237dd1b57ce7 100644 (file)
@@ -1,5 +1,5 @@
-import {onSelect} from '../services/dom';
-import {KeyboardNavigationHandler} from '../services/keyboard-navigation';
+import {onSelect} from '../services/dom.ts';
+import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
 import {Component} from './component';
 
 /**
index 920fe875f22135de5a302dee0a8e44d7207aaf86..598e0d8d48b685cdb58a9782d5addc4eafa64732 100644 (file)
@@ -2,7 +2,7 @@ import {Component} from './component';
 import {Clipboard} from '../services/clipboard.ts';
 import {
     elem, getLoading, onSelect, removeLoading,
-} from '../services/dom';
+} from '../services/dom.ts';
 
 export class Dropzone extends Component {
 
index 7ab99a2a70bb2f45445d485b6aff49e018b25d7b..b020c5d85ba8f785785df1e2f98e2607f0e9b385 100644 (file)
@@ -1,4 +1,4 @@
-import {htmlToDom} from '../services/dom';
+import {htmlToDom} from '../services/dom.ts';
 import {Component} from './component';
 
 export class EntityPermissions extends Component {
index 7a50444708dee6313ab5b2caee5a2dd21def7cdb..9d45133266d7e4095219970ecc22361d9368aa2d 100644 (file)
@@ -1,4 +1,4 @@
-import {onSelect} from '../services/dom';
+import {onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 export class EntitySearch extends Component {
index 561370d7a342004dacc592e81d5366a07e1d00a9..7491119a137ffbb24d0d83f0f4be1ec46add9ac7 100644 (file)
@@ -1,4 +1,4 @@
-import {onChildEvent} from '../services/dom';
+import {onChildEvent} from '../services/dom.ts';
 import {Component} from './component';
 
 /**
index 2097c0528868181cdc9e94c67cc630fdfca18652..f722a25e71b75faebb784b0ddbc044af3099fd76 100644 (file)
@@ -1,4 +1,4 @@
-import {onSelect} from '../services/dom';
+import {onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 /**
index 0d2018b9da23531b27d98b18db682335ab47615b..29173a058293d99f88ed25390892961ea39e92e1 100644 (file)
@@ -1,4 +1,4 @@
-import {slideUp, slideDown} from '../services/animations';
+import {slideUp, slideDown} from '../services/animations.ts';
 import {Component} from './component';
 
 export class ExpandToggle extends Component {
index 798bd7aacb0d5c00fb5d0ece92c6ae604678a235..2cdaf591ac458d99c13c5d3f8638d86a52ef3141 100644 (file)
@@ -1,6 +1,6 @@
-import {htmlToDom} from '../services/dom';
-import {debounce} from '../services/util';
-import {KeyboardNavigationHandler} from '../services/keyboard-navigation';
+import {htmlToDom} from '../services/dom.ts';
+import {debounce} from '../services/util.ts';
+import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
 import {Component} from './component';
 
 /**
index 47231477b684fc00e1d453bbc5a83d88a3188500..c8108ab28c19f6b456e419a50833ae8cd98f9b34 100644 (file)
@@ -1,6 +1,6 @@
 import {
     onChildEvent, onSelect, removeLoading, showLoading,
-} from '../services/dom';
+} from '../services/dom.ts';
 import {Component} from './component';
 
 export class ImageManager extends Component {
similarity index 98%
rename from resources/js/components/index.js
rename to resources/js/components/index.ts
index 8ad5e14cb2e41b6c89570c6b11714a819589f181..24e60bd97f242a3bbc7bd7e0eccd68864b451229 100644 (file)
@@ -30,6 +30,7 @@ export {HeaderMobileToggle} from './header-mobile-toggle';
 export {ImageManager} from './image-manager';
 export {ImagePicker} from './image-picker';
 export {ListSortControl} from './list-sort-control';
+export {LoadingButton} from './loading-button.ts';
 export {MarkdownEditor} from './markdown-editor';
 export {NewUserPassword} from './new-user-password';
 export {Notification} from './notification';
diff --git a/resources/js/components/loading-button.ts b/resources/js/components/loading-button.ts
new file mode 100644 (file)
index 0000000..a793d30
--- /dev/null
@@ -0,0 +1,38 @@
+import {Component} from "./component.js";
+import {showLoading} from "../services/dom";
+import {el} from "../wysiwyg/utils/dom";
+
+/**
+ * Loading button.
+ * Shows a loading indicator and disables the button when the button is clicked,
+ * or when the form attached to the button is submitted.
+ */
+export class LoadingButton extends Component {
+
+    protected button!: HTMLButtonElement;
+    protected loadingEl: HTMLDivElement|null = null;
+
+    setup() {
+        this.button = this.$el as HTMLButtonElement;
+        const form = this.button.form;
+
+        const action = () => {
+            setTimeout(() => this.showLoadingState(), 10)
+        };
+
+        this.button.addEventListener('click', action);
+        if (form) {
+            form.addEventListener('submit', action);
+        }
+    }
+
+    showLoadingState() {
+        this.button.disabled = true;
+
+        if (!this.loadingEl) {
+            this.loadingEl = el('div', {class: 'inline block'}) as HTMLDivElement;
+            showLoading(this.loadingEl);
+            this.button.after(this.loadingEl);
+        }
+    }
+}
\ No newline at end of file
index 64cee12cd29b6a212a2fb882be6a0be193598a46..1b133047d00d1d23d2bec486ee5ca7a84beb0af9 100644 (file)
@@ -1,4 +1,4 @@
-import {onSelect} from '../services/dom';
+import {onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 export class OptionalInput extends Component {
index fd8ad1f2e47b0ac5a5c6494f7ffcc9c5b2df5d8e..8c0a8b33e5406a4659a8ff7cfe97407a1acc6df8 100644 (file)
@@ -1,5 +1,5 @@
 import {Component} from './component';
-import {getLoading, htmlToDom} from '../services/dom';
+import {getLoading, htmlToDom} from '../services/dom.ts';
 import {buildForInput} from '../wysiwyg-tinymce/config';
 
 export class PageComment extends Component {
index 1d6abfe2044ff3a0a9e006f29e04fb4fef94601c..8f023836b090876360c120df95e3c1f0a137bf18 100644 (file)
@@ -1,5 +1,5 @@
 import {Component} from './component';
-import {getLoading, htmlToDom} from '../services/dom';
+import {getLoading, htmlToDom} from '../services/dom.ts';
 import {buildForInput} from '../wysiwyg-tinymce/config';
 
 export class PageComments extends Component {
@@ -93,7 +93,6 @@ export class PageComments extends Component {
 
     updateCount() {
         const count = this.getCommentCount();
-        console.log('update count', count, this.container);
         this.commentsTitle.textContent = window.$trans.choice(this.countText, count, {count});
     }
 
index 1e13ae38800ebf6cd86a588cbe7e07ccc0957200..d3ac78a4ad19347779583d1331cb470d6532959a 100644 (file)
@@ -1,5 +1,5 @@
-import * as DOM from '../services/dom';
-import {scrollAndHighlightElement} from '../services/util';
+import * as DOM from '../services/dom.ts';
+import {scrollAndHighlightElement} from '../services/util.ts';
 import {Component} from './component';
 
 function toggleAnchorHighlighting(elementId, shouldHighlight) {
index 2160675529bd5eef9a132141af2f7f9ee853dc01..7ffceb0f9048a281f53ae82dfe31732e52d17247 100644 (file)
@@ -1,5 +1,5 @@
-import {onSelect} from '../services/dom';
-import {debounce} from '../services/util';
+import {onSelect} from '../services/dom.ts';
+import {debounce} from '../services/util.ts';
 import {Component} from './component';
 import {utcTimeStampToLocalTime} from '../services/dates.ts';
 
index 607576cb9f9595a6cb9382c44e6c8a7f6002cba4..292b923e5519f47742990918214c7f46b7e9140b 100644 (file)
@@ -1,4 +1,4 @@
-import * as DOM from '../services/dom';
+import * as DOM from '../services/dom.ts';
 import {Component} from './component';
 import {copyTextToClipboard} from '../services/clipboard.ts';
 
index 6627365483e69f6c90b37d5eac7528f1f0c71be3..6bd8f9c722b7044b9ae9ab5cd73867e8383a5434 100644 (file)
@@ -1,5 +1,5 @@
-import {fadeIn, fadeOut} from '../services/animations';
-import {onSelect} from '../services/dom';
+import {fadeIn, fadeOut} from '../services/animations.ts';
+import {onSelect} from '../services/dom.ts';
 import {Component} from './component';
 
 /**
index 56ec876d48bbb66a78e65038e4bfdd45a09759d5..cf81990ab3f934539bdc104a3226d2575e745d9e 100644 (file)
@@ -1,4 +1,4 @@
-import * as DOM from '../services/dom';
+import * as DOM from '../services/dom.ts';
 import {Component} from './component';
 
 export class TemplateManager extends Component {
index e6adc3c23c82d4e163c12c4f4c42adfc1d80d249..f9ec03ed366a4c6ea4bd643dea0ab7e524ff5ae7 100644 (file)
@@ -1,4 +1,4 @@
-import {onChildEvent} from '../services/dom';
+import {onChildEvent} from '../services/dom.ts';
 import {Component} from './component';
 
 export class UserSelect extends Component {
index e505c96e0d4a24f1a5d83fcee75a9e1b70962273..b637c97c1b92a7cf55dd3556c427d4e0e1992e54 100644 (file)
@@ -7,10 +7,12 @@ declare global {
     const __DEV__: boolean;
 
     interface Window {
+        __DEV__: boolean;
         $components: ComponentStore;
         $events: EventManager;
         $trans: Translator;
         $http: HttpManager;
         baseUrl: (path: string) => string;
+        importVersioned: (module: string) => Promise<object>;
     }
 }
\ No newline at end of file
index a6332cbb844fddc44d022e9708b0051fca5b41e3..664767605b8a91b33125512efc96476068da4c32 100644 (file)
@@ -1,5 +1,5 @@
 import {provideKeyBindings} from './shortcuts';
-import {debounce} from '../services/util';
+import {debounce} from '../services/util.ts';
 import {Clipboard} from '../services/clipboard.ts';
 
 /**
similarity index 63%
rename from resources/js/services/animations.js
rename to resources/js/services/animations.ts
index bc983c8072aa529d7693018716c7b0ed255db087..adf4cb3c932f66afb140fbcd66386dbdb6adeb40 100644 (file)
@@ -1,30 +1,30 @@
 /**
  * Used in the function below to store references of clean-up functions.
  * Used to ensure only one transitionend function exists at any time.
- * @type {WeakMap<object, any>}
  */
-const animateStylesCleanupMap = new WeakMap();
+const animateStylesCleanupMap: WeakMap<object, any> = new WeakMap();
 
 /**
  * Animate the css styles of an element using FLIP animation techniques.
- * Styles must be an object where the keys are style properties, camelcase, and the values
+ * Styles must be an object where the keys are style rule names and the values
  * are an array of two items in the format [initialValue, finalValue]
- * @param {Element} element
- * @param {Object} styles
- * @param {Number} animTime
- * @param {Function} onComplete
  */
-function animateStyles(element, styles, animTime = 400, onComplete = null) {
+function animateStyles(
+    element: HTMLElement,
+    styles: Record<string, string[]>,
+    animTime: number = 400,
+    onComplete: Function | null = null
+): void {
     const styleNames = Object.keys(styles);
     for (const style of styleNames) {
-        element.style[style] = styles[style][0];
+        element.style.setProperty(style, styles[style][0]);
     }
 
     const cleanup = () => {
         for (const style of styleNames) {
-            element.style[style] = null;
+            element.style.removeProperty(style);
         }
-        element.style.transition = null;
+        element.style.removeProperty('transition');
         element.removeEventListener('transitionend', cleanup);
         animateStylesCleanupMap.delete(element);
         if (onComplete) onComplete();
@@ -33,7 +33,7 @@ function animateStyles(element, styles, animTime = 400, onComplete = null) {
     setTimeout(() => {
         element.style.transition = `all ease-in-out ${animTime}ms`;
         for (const style of styleNames) {
-            element.style[style] = styles[style][1];
+            element.style.setProperty(style, styles[style][1]);
         }
 
         element.addEventListener('transitionend', cleanup);
@@ -43,9 +43,8 @@ function animateStyles(element, styles, animTime = 400, onComplete = null) {
 
 /**
  * Run the active cleanup action for the given element.
- * @param {Element} element
  */
-function cleanupExistingElementAnimation(element) {
+function cleanupExistingElementAnimation(element: Element) {
     if (animateStylesCleanupMap.has(element)) {
         const oldCleanup = animateStylesCleanupMap.get(element);
         oldCleanup();
@@ -54,15 +53,12 @@ function cleanupExistingElementAnimation(element) {
 
 /**
  * Fade in the given element.
- * @param {Element} element
- * @param {Number} animTime
- * @param {Function|null} onComplete
  */
-export function fadeIn(element, animTime = 400, onComplete = null) {
+export function fadeIn(element: HTMLElement, animTime: number = 400, onComplete: Function | null = null): void {
     cleanupExistingElementAnimation(element);
     element.style.display = 'block';
     animateStyles(element, {
-        opacity: ['0', '1'],
+        'opacity': ['0', '1'],
     }, animTime, () => {
         if (onComplete) onComplete();
     });
@@ -70,14 +66,11 @@ export function fadeIn(element, animTime = 400, onComplete = null) {
 
 /**
  * Fade out the given element.
- * @param {Element} element
- * @param {Number} animTime
- * @param {Function|null} onComplete
  */
-export function fadeOut(element, animTime = 400, onComplete = null) {
+export function fadeOut(element: HTMLElement, animTime: number = 400, onComplete: Function | null = null): void {
     cleanupExistingElementAnimation(element);
     animateStyles(element, {
-        opacity: ['1', '0'],
+        'opacity': ['1', '0'],
     }, animTime, () => {
         element.style.display = 'none';
         if (onComplete) onComplete();
@@ -86,20 +79,18 @@ export function fadeOut(element, animTime = 400, onComplete = null) {
 
 /**
  * Hide the element by sliding the contents upwards.
- * @param {Element} element
- * @param {Number} animTime
  */
-export function slideUp(element, animTime = 400) {
+export function slideUp(element: HTMLElement, animTime: number = 400) {
     cleanupExistingElementAnimation(element);
     const currentHeight = element.getBoundingClientRect().height;
     const computedStyles = getComputedStyle(element);
     const currentPaddingTop = computedStyles.getPropertyValue('padding-top');
     const currentPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
     const animStyles = {
-        maxHeight: [`${currentHeight}px`, '0px'],
-        overflow: ['hidden', 'hidden'],
-        paddingTop: [currentPaddingTop, '0px'],
-        paddingBottom: [currentPaddingBottom, '0px'],
+        'max-height': [`${currentHeight}px`, '0px'],
+        'overflow': ['hidden', 'hidden'],
+        'padding-top': [currentPaddingTop, '0px'],
+        'padding-bottom': [currentPaddingBottom, '0px'],
     };
 
     animateStyles(element, animStyles, animTime, () => {
@@ -109,10 +100,8 @@ export function slideUp(element, animTime = 400) {
 
 /**
  * Show the given element by expanding the contents.
- * @param {Element} element - Element to animate
- * @param {Number} animTime - Animation time in ms
  */
-export function slideDown(element, animTime = 400) {
+export function slideDown(element: HTMLElement, animTime: number = 400) {
     cleanupExistingElementAnimation(element);
     element.style.display = 'block';
     const targetHeight = element.getBoundingClientRect().height;
@@ -120,10 +109,10 @@ export function slideDown(element, animTime = 400) {
     const targetPaddingTop = computedStyles.getPropertyValue('padding-top');
     const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
     const animStyles = {
-        maxHeight: ['0px', `${targetHeight}px`],
-        overflow: ['hidden', 'hidden'],
-        paddingTop: ['0px', targetPaddingTop],
-        paddingBottom: ['0px', targetPaddingBottom],
+        'max-height': ['0px', `${targetHeight}px`],
+        'overflow': ['hidden', 'hidden'],
+        'padding-top': ['0px', targetPaddingTop],
+        'padding-bottom': ['0px', targetPaddingBottom],
     };
 
     animateStyles(element, animStyles, animTime);
@@ -134,11 +123,8 @@ export function slideDown(element, animTime = 400) {
  * Call with first state, and you'll receive a function in return.
  * Call the returned function in the second state to animate between those two states.
  * If animating to/from 0-height use the slide-up/slide down as easier alternatives.
- * @param {Element} element - Element to animate
- * @param {Number} animTime - Animation time in ms
- * @returns {function} - Function to run in second state to trigger animation.
  */
-export function transitionHeight(element, animTime = 400) {
+export function transitionHeight(element: HTMLElement, animTime: number = 400): () => void {
     const startHeight = element.getBoundingClientRect().height;
     const initialComputedStyles = getComputedStyle(element);
     const startPaddingTop = initialComputedStyles.getPropertyValue('padding-top');
@@ -151,10 +137,10 @@ export function transitionHeight(element, animTime = 400) {
         const targetPaddingTop = computedStyles.getPropertyValue('padding-top');
         const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
         const animStyles = {
-            height: [`${startHeight}px`, `${targetHeight}px`],
-            overflow: ['hidden', 'hidden'],
-            paddingTop: [startPaddingTop, targetPaddingTop],
-            paddingBottom: [startPaddingBottom, targetPaddingBottom],
+            'height': [`${startHeight}px`, `${targetHeight}px`],
+            'overflow': ['hidden', 'hidden'],
+            'padding-top': [startPaddingTop, targetPaddingTop],
+            'padding-bottom': [startPaddingBottom, targetPaddingBottom],
         };
 
         animateStyles(element, animStyles, animTime);
similarity index 63%
rename from resources/js/services/dom.js
rename to resources/js/services/dom.ts
index bcfd0b565da2ab9b9144af2243d2cdb2fe13cc50..c88827bac40a1788b89295152799b62e9871425f 100644 (file)
@@ -1,12 +1,15 @@
+/**
+ * Check if the given param is a HTMLElement
+ */
+export function isHTMLElement(el: any): el is HTMLElement {
+    return el instanceof HTMLElement;
+}
+
 /**
  * Create a new element with the given attrs and children.
  * Children can be a string for text nodes or other elements.
- * @param {String} tagName
- * @param {Object<String, String>} attrs
- * @param {Element[]|String[]}children
- * @return {*}
  */
-export function elem(tagName, attrs = {}, children = []) {
+export function elem(tagName: string, attrs: Record<string, string> = {}, children: Element[]|string[] = []): HTMLElement {
     const el = document.createElement(tagName);
 
     for (const [key, val] of Object.entries(attrs)) {
@@ -30,10 +33,8 @@ export function elem(tagName, attrs = {}, children = []) {
 
 /**
  * Run the given callback against each element that matches the given selector.
- * @param {String} selector
- * @param {Function<Element>} callback
  */
-export function forEach(selector, callback) {
+export function forEach(selector: string, callback: (el: Element) => any) {
     const elements = document.querySelectorAll(selector);
     for (const element of elements) {
         callback(element);
@@ -42,11 +43,8 @@ export function forEach(selector, callback) {
 
 /**
  * Helper to listen to multiple DOM events
- * @param {Element} listenerElement
- * @param {Array<String>} events
- * @param {Function<Event>} callback
  */
-export function onEvents(listenerElement, events, callback) {
+export function onEvents(listenerElement: Element, events: string[], callback: (e: Event) => any): void {
     for (const eventName of events) {
         listenerElement.addEventListener(eventName, callback);
     }
@@ -55,10 +53,8 @@ export function onEvents(listenerElement, events, callback) {
 /**
  * Helper to run an action when an element is selected.
  * A "select" is made to be accessible, So can be a click, space-press or enter-press.
- * @param {HTMLElement|Array} elements
- * @param {function} callback
  */
-export function onSelect(elements, callback) {
+export function onSelect(elements: HTMLElement|HTMLElement[], callback: (e: Event) => any): void {
     if (!Array.isArray(elements)) {
         elements = [elements];
     }
@@ -76,16 +72,13 @@ export function onSelect(elements, callback) {
 
 /**
  * Listen to key press on the given element(s).
- * @param {String} key
- * @param {HTMLElement|Array} elements
- * @param {function} callback
  */
-function onKeyPress(key, elements, callback) {
+function onKeyPress(key: string, elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void {
     if (!Array.isArray(elements)) {
         elements = [elements];
     }
 
-    const listener = event => {
+    const listener = (event: KeyboardEvent) => {
         if (event.key === key) {
             callback(event);
         }
@@ -96,19 +89,15 @@ function onKeyPress(key, elements, callback) {
 
 /**
  * Listen to enter press on the given element(s).
- * @param {HTMLElement|Array} elements
- * @param {function} callback
  */
-export function onEnterPress(elements, callback) {
+export function onEnterPress(elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void {
     onKeyPress('Enter', elements, callback);
 }
 
 /**
  * Listen to escape press on the given element(s).
- * @param {HTMLElement|Array} elements
- * @param {function} callback
  */
-export function onEscapePress(elements, callback) {
+export function onEscapePress(elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void {
     onKeyPress('Escape', elements, callback);
 }
 
@@ -116,14 +105,15 @@ export function onEscapePress(elements, callback) {
  * Set a listener on an element for an event emitted by a child
  * matching the given childSelector param.
  * Used in a similar fashion to jQuery's $('listener').on('eventName', 'childSelector', callback)
- * @param {Element} listenerElement
- * @param {String} childSelector
- * @param {String} eventName
- * @param {Function} callback
  */
-export function onChildEvent(listenerElement, childSelector, eventName, callback) {
-    listenerElement.addEventListener(eventName, event => {
-        const matchingChild = event.target.closest(childSelector);
+export function onChildEvent(
+    listenerElement: HTMLElement,
+    childSelector: string,
+    eventName: string,
+    callback: (this: HTMLElement, e: Event, child: HTMLElement) => any
+): void {
+    listenerElement.addEventListener(eventName, (event: Event) => {
+        const matchingChild = (event.target as HTMLElement|null)?.closest(childSelector) as HTMLElement;
         if (matchingChild) {
             callback.call(matchingChild, event, matchingChild);
         }
@@ -132,16 +122,13 @@ export function onChildEvent(listenerElement, childSelector, eventName, callback
 
 /**
  * Look for elements that match the given selector and contain the given text.
- * Is case insensitive and returns the first result or null if nothing is found.
- * @param {String} selector
- * @param {String} text
- * @returns {Element}
+ * Is case-insensitive and returns the first result or null if nothing is found.
  */
-export function findText(selector, text) {
+export function findText(selector: string, text: string): Element|null {
     const elements = document.querySelectorAll(selector);
     text = text.toLowerCase();
     for (const element of elements) {
-        if (element.textContent.toLowerCase().includes(text)) {
+        if ((element.textContent || '').toLowerCase().includes(text) && isHTMLElement(element)) {
             return element;
         }
     }
@@ -151,17 +138,15 @@ export function findText(selector, text) {
 /**
  * Show a loading indicator in the given element.
  * This will effectively clear the element.
- * @param {Element} element
  */
-export function showLoading(element) {
+export function showLoading(element: HTMLElement): void {
     element.innerHTML = '<div class="loading-container"><div></div><div></div><div></div></div>';
 }
 
 /**
  * Get a loading element indicator element.
- * @returns {Element}
  */
-export function getLoading() {
+export function getLoading(): HTMLElement {
     const wrap = document.createElement('div');
     wrap.classList.add('loading-container');
     wrap.innerHTML = '<div></div><div></div><div></div>';
@@ -170,9 +155,8 @@ export function getLoading() {
 
 /**
  * Remove any loading indicators within the given element.
- * @param {Element} element
  */
-export function removeLoading(element) {
+export function removeLoading(element: HTMLElement): void {
     const loadingEls = element.querySelectorAll('.loading-container');
     for (const el of loadingEls) {
         el.remove();
@@ -182,12 +166,15 @@ export function removeLoading(element) {
 /**
  * Convert the given html data into a live DOM element.
  * Initiates any components defined in the data.
- * @param {String} html
- * @returns {Element}
  */
-export function htmlToDom(html) {
+export function htmlToDom(html: string): HTMLElement {
     const wrap = document.createElement('div');
     wrap.innerHTML = html;
     window.$components.init(wrap);
-    return wrap.children[0];
+    const firstChild = wrap.children[0];
+    if (!isHTMLElement(firstChild)) {
+        throw new Error('Could not find child HTMLElement when creating DOM element from HTML');
+    }
+
+    return firstChild;
 }
similarity index 66%
rename from resources/js/services/keyboard-navigation.js
rename to resources/js/services/keyboard-navigation.ts
index 34111bb2d37886e3d98a7e150fd7ada800ba186b..13fbdfecc9d81561bec3ef7ce183e605f914fce7 100644 (file)
@@ -1,14 +1,17 @@
+import {isHTMLElement} from "./dom";
+
+type OptionalKeyEventHandler = ((e: KeyboardEvent) => any)|null;
+
 /**
  * Handle common keyboard navigation events within a given container.
  */
 export class KeyboardNavigationHandler {
 
-    /**
-     * @param {Element} container
-     * @param {Function|null} onEscape
-     * @param {Function|null} onEnter
-     */
-    constructor(container, onEscape = null, onEnter = null) {
+    protected containers: HTMLElement[];
+    protected onEscape: OptionalKeyEventHandler;
+    protected onEnter: OptionalKeyEventHandler;
+
+    constructor(container: HTMLElement, onEscape: OptionalKeyEventHandler = null, onEnter: OptionalKeyEventHandler = null) {
         this.containers = [container];
         this.onEscape = onEscape;
         this.onEnter = onEnter;
@@ -18,9 +21,8 @@ export class KeyboardNavigationHandler {
     /**
      * Also share the keyboard event handling to the given element.
      * Only elements within the original container are considered focusable though.
-     * @param {Element} element
      */
-    shareHandlingToEl(element) {
+    shareHandlingToEl(element: HTMLElement) {
         this.containers.push(element);
         element.addEventListener('keydown', this.#keydownHandler.bind(this));
     }
@@ -30,7 +32,8 @@ export class KeyboardNavigationHandler {
      */
     focusNext() {
         const focusable = this.#getFocusable();
-        const currentIndex = focusable.indexOf(document.activeElement);
+        const activeEl = document.activeElement;
+        const currentIndex = isHTMLElement(activeEl) ? focusable.indexOf(activeEl) : -1;
         let newIndex = currentIndex + 1;
         if (newIndex >= focusable.length) {
             newIndex = 0;
@@ -44,7 +47,8 @@ export class KeyboardNavigationHandler {
      */
     focusPrevious() {
         const focusable = this.#getFocusable();
-        const currentIndex = focusable.indexOf(document.activeElement);
+        const activeEl = document.activeElement;
+        const currentIndex = isHTMLElement(activeEl) ? focusable.indexOf(activeEl) : -1;
         let newIndex = currentIndex - 1;
         if (newIndex < 0) {
             newIndex = focusable.length - 1;
@@ -53,12 +57,9 @@ export class KeyboardNavigationHandler {
         focusable[newIndex].focus();
     }
 
-    /**
-     * @param {KeyboardEvent} event
-     */
-    #keydownHandler(event) {
+    #keydownHandler(event: KeyboardEvent) {
         // Ignore certain key events in inputs to allow text editing.
-        if (event.target.matches('input') && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
+        if (isHTMLElement(event.target) && event.target.matches('input') && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
             return;
         }
 
@@ -71,7 +72,7 @@ export class KeyboardNavigationHandler {
         } else if (event.key === 'Escape') {
             if (this.onEscape) {
                 this.onEscape(event);
-            } else if (document.activeElement) {
+            } else if (isHTMLElement(document.activeElement)) {
                 document.activeElement.blur();
             }
         } else if (event.key === 'Enter' && this.onEnter) {
@@ -81,14 +82,15 @@ export class KeyboardNavigationHandler {
 
     /**
      * Get an array of focusable elements within the current containers.
-     * @returns {Element[]}
      */
-    #getFocusable() {
-        const focusable = [];
+    #getFocusable(): HTMLElement[] {
+        const focusable: HTMLElement[] = [];
         const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])';
         for (const container of this.containers) {
-            focusable.push(...container.querySelectorAll(selector));
+            const toAdd = [...container.querySelectorAll(selector)].filter(e => isHTMLElement(e));
+            focusable.push(...toAdd);
         }
+
         return focusable;
     }
 
diff --git a/resources/js/services/util.js b/resources/js/services/util.js
deleted file mode 100644 (file)
index 1264d10..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * Returns a function, that, as long as it continues to be invoked, will not
- * be triggered. The function will be called after it stops being called for
- * N milliseconds. If `immediate` is passed, trigger the function on the
- * leading edge, instead of the trailing.
- * @attribution https://davidwalsh.name/javascript-debounce-function
- * @param {Function} func
- * @param {Number} waitMs
- * @param {Boolean} immediate
- * @returns {Function}
- */
-export function debounce(func, waitMs, immediate) {
-    let timeout;
-    return function debouncedWrapper(...args) {
-        const context = this;
-        const later = function debouncedTimeout() {
-            timeout = null;
-            if (!immediate) func.apply(context, args);
-        };
-        const callNow = immediate && !timeout;
-        clearTimeout(timeout);
-        timeout = setTimeout(later, waitMs);
-        if (callNow) func.apply(context, args);
-    };
-}
-
-/**
- * Scroll and highlight an element.
- * @param {HTMLElement} element
- */
-export function scrollAndHighlightElement(element) {
-    if (!element) return;
-
-    let parent = element;
-    while (parent.parentElement) {
-        parent = parent.parentElement;
-        if (parent.nodeName === 'DETAILS' && !parent.open) {
-            parent.open = true;
-        }
-    }
-
-    element.scrollIntoView({behavior: 'smooth'});
-
-    const highlight = getComputedStyle(document.body).getPropertyValue('--color-link');
-    element.style.outline = `2px dashed ${highlight}`;
-    element.style.outlineOffset = '5px';
-    element.style.transition = null;
-    setTimeout(() => {
-        element.style.transition = 'outline linear 3s';
-        element.style.outline = '2px dashed rgba(0, 0, 0, 0)';
-        const listener = () => {
-            element.removeEventListener('transitionend', listener);
-            element.style.transition = null;
-            element.style.outline = null;
-            element.style.outlineOffset = null;
-        };
-        element.addEventListener('transitionend', listener);
-    }, 1000);
-}
-
-/**
- * Escape any HTML in the given 'unsafe' string.
- * Take from https://stackoverflow.com/a/6234804.
- * @param {String} unsafe
- * @returns {string}
- */
-export function escapeHtml(unsafe) {
-    return unsafe
-        .replace(/&/g, '&amp;')
-        .replace(/</g, '&lt;')
-        .replace(/>/g, '&gt;')
-        .replace(/"/g, '&quot;')
-        .replace(/'/g, '&#039;');
-}
-
-/**
- * Generate a random unique ID.
- *
- * @returns {string}
- */
-export function uniqueId() {
-    // eslint-disable-next-line no-bitwise
-    const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
-    return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`);
-}
-
-/**
- * Generate a random smaller unique ID.
- *
- * @returns {string}
- */
-export function uniqueIdSmall() {
-    // eslint-disable-next-line no-bitwise
-    const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
-    return S4();
-}
-
-/**
- * Create a promise that resolves after the given time.
- * @param {int} timeMs
- * @returns {Promise}
- */
-export function wait(timeMs) {
-    return new Promise(res => {
-        setTimeout(res, timeMs);
-    });
-}
diff --git a/resources/js/services/util.ts b/resources/js/services/util.ts
new file mode 100644 (file)
index 0000000..c5a5d2d
--- /dev/null
@@ -0,0 +1,147 @@
+/**
+ * Returns a function, that, as long as it continues to be invoked, will not
+ * be triggered. The function will be called after it stops being called for
+ * N milliseconds. If `immediate` is passed, trigger the function on the
+ * leading edge, instead of the trailing.
+ * @attribution https://davidwalsh.name/javascript-debounce-function
+ */
+export function debounce(func: Function, waitMs: number, immediate: boolean): Function {
+    let timeout: number|null = null;
+    return function debouncedWrapper(this: any, ...args: any[]) {
+        const context: any = this;
+        const later = function debouncedTimeout() {
+            timeout = null;
+            if (!immediate) func.apply(context, args);
+        };
+        const callNow = immediate && !timeout;
+        if (timeout) {
+            clearTimeout(timeout);
+        }
+        timeout = window.setTimeout(later, waitMs);
+        if (callNow) func.apply(context, args);
+    };
+}
+
+function isDetailsElement(element: HTMLElement): element is HTMLDetailsElement {
+    return element.nodeName === 'DETAILS';
+}
+
+/**
+ * Scroll-to and highlight an element.
+ */
+export function scrollAndHighlightElement(element: HTMLElement): void {
+    if (!element) return;
+
+    // Open up parent <details> elements if within
+    let parent = element;
+    while (parent.parentElement) {
+        parent = parent.parentElement;
+        if (isDetailsElement(parent) && !parent.open) {
+            parent.open = true;
+        }
+    }
+
+    element.scrollIntoView({behavior: 'smooth'});
+
+    const highlight = getComputedStyle(document.body).getPropertyValue('--color-link');
+    element.style.outline = `2px dashed ${highlight}`;
+    element.style.outlineOffset = '5px';
+    element.style.removeProperty('transition');
+    setTimeout(() => {
+        element.style.transition = 'outline linear 3s';
+        element.style.outline = '2px dashed rgba(0, 0, 0, 0)';
+        const listener = () => {
+            element.removeEventListener('transitionend', listener);
+            element.style.removeProperty('transition');
+            element.style.removeProperty('outline');
+            element.style.removeProperty('outlineOffset');
+        };
+        element.addEventListener('transitionend', listener);
+    }, 1000);
+}
+
+/**
+ * Escape any HTML in the given 'unsafe' string.
+ * Take from https://stackoverflow.com/a/6234804.
+ */
+export function escapeHtml(unsafe: string): string {
+    return unsafe
+        .replace(/&/g, '&amp;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;')
+        .replace(/"/g, '&quot;')
+        .replace(/'/g, '&#039;');
+}
+
+/**
+ * Generate a random unique ID.
+ */
+export function uniqueId(): string {
+    // eslint-disable-next-line no-bitwise
+    const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
+    return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`);
+}
+
+/**
+ * Generate a random smaller unique ID.
+ */
+export function uniqueIdSmall(): string {
+    // eslint-disable-next-line no-bitwise
+    const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
+    return S4();
+}
+
+/**
+ * Create a promise that resolves after the given time.
+ */
+export function wait(timeMs: number): Promise<any> {
+    return new Promise(res => {
+        setTimeout(res, timeMs);
+    });
+}
+
+/**
+ * Generate a full URL from the given relative URL, using a base
+ * URL defined in the head of the page.
+ */
+export function baseUrl(path: string): string {
+    let targetPath = path;
+    const baseUrlMeta = document.querySelector('meta[name="base-url"]');
+    if (!baseUrlMeta) {
+        throw new Error('Could not find expected base-url meta tag in document');
+    }
+
+    let basePath = baseUrlMeta.getAttribute('content') || '';
+    if (basePath[basePath.length - 1] === '/') {
+        basePath = basePath.slice(0, basePath.length - 1);
+    }
+
+    if (targetPath[0] === '/') {
+        targetPath = targetPath.slice(1);
+    }
+
+    return `${basePath}/${targetPath}`;
+}
+
+/**
+ * Get the current version of BookStack in use.
+ * Grabs this from the version query used on app assets.
+ */
+function getVersion(): string {
+    const styleLink = document.querySelector('link[href*="/dist/styles.css?version="]');
+    if (!styleLink) {
+        throw new Error('Could not find expected style link in document for version use');
+    }
+
+    const href = (styleLink.getAttribute('href') || '');
+    return href.split('?version=').pop() || '';
+}
+
+/**
+ * Perform a module import, Ensuring the import is fetched with the current
+ * app version as a cache-breaker.
+ */
+export function importVersioned(moduleName: string): Promise<object> {
+    const importPath = window.baseUrl(`dist/${moduleName}.js?version=${getVersion()}`);
+    return import(importPath);
+}
\ No newline at end of file
index 342cac0af74c4df3677482e5efc8ba4a92e60f90..197c50b0e448aec7c7d62f3a25f943ecf4112a1e 100644 (file)
@@ -1,5 +1,5 @@
 import * as DrawIO from '../services/drawio.ts';
-import {wait} from '../services/util';
+import {wait} from '../services/util.ts';
 
 let pageEditor = null;
 let currentNode = null;
index c79671d76467a4baf3b8edcab39598a769b996ce..5f14cb9dbc90a3230dfe632313b41192c5163463 100644 (file)
   border-radius: 4px;
 }
 
+.cm-editor .cm-line {
+  line-height: 1.6;
+}
+
 .cm-editor .cm-line, .cm-editor .cm-gutter {
   font-family: var(--font-code);
 }
index 67df4171499f841407d927e05e443346a0df762d..1c679aaa0ddef2462d6bbd705e2d6da3cf85e4fe 100644 (file)
@@ -545,6 +545,43 @@ input[type=color] {
   outline: 1px solid var(--color-primary);
 }
 
+.custom-simple-file-input {
+  max-width: 100%;
+  border: 1px solid;
+  @include lightDark(border-color, #DDD, #666);
+  border-radius: 4px;
+  padding: $-s $-m;
+}
+.custom-simple-file-input::file-selector-button {
+  background-color: transparent;
+  text-decoration: none;
+  font-size: 0.8rem;
+  line-height: 1.4em;
+  padding: $-xs $-s;
+  border: 1px solid;
+  font-weight: 400;
+  outline: 0;
+  border-radius: 4px;
+  cursor: pointer;
+  margin-right: $-m;
+  @include lightDark(color, #666, #AAA);
+  @include lightDark(border-color, #CCC, #666);
+  &:hover, &:focus, &:active {
+    @include lightDark(color, #444, #BBB);
+    border: 1px solid #CCC;
+    box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1);
+    background-color: #F2F2F2;
+    @include lightDark(background-color, #f8f8f8, #444);
+    filter: none;
+  }
+  &:active {
+    border-color: #BBB;
+    background-color: #DDD;
+    color: #666;
+    box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.1);
+  }
+}
+
 input.shortcut-input {
   width: auto;
   max-width: 120px;
index 636367e3aeb0f14cce35fa77e4f903455914335d..2106f86e62509a7e0cc82e2910130ab805227517 100644 (file)
@@ -106,6 +106,10 @@ $loadingSize: 10px;
   }
 }
 
+.inline.block .loading-container {
+  margin: $-xs $-s;
+}
+
 .skip-to-content-link {
   position: fixed;
   top: -52px;
@@ -138,6 +142,11 @@ $loadingSize: 10px;
     font-size: 16px;
     padding: $-s $-m;
   }
+  input[type="text"]:focus {
+    outline: 1px solid var(--color-primary);
+    border-radius: 3px 3px 0 0;
+    outline-offset: -1px;
+  }
   .entity-list {
     overflow-y: scroll;
     height: 400px;
@@ -171,6 +180,19 @@ $loadingSize: 10px;
       font-size: 14px;
     }
   }
+  &.small {
+    .entity-list-item {
+      padding: $-xs $-m;
+    }
+    .entity-list, .loading {
+      height: 300px;
+    }
+    input[type="text"] {
+      font-size: 13px;
+      padding: $-xs $-m;
+      height: auto;
+    }
+  }
 }
 
 .fullscreen {
@@ -230,4 +252,9 @@ $loadingSize: 10px;
       transform: rotate(180deg);
     }
   }
+}
+
+.import-item {
+  border-inline-start: 2px solid currentColor;
+  padding-inline-start: $-xs;
 }
\ No newline at end of file
index 0b407a8609abb98f3b6ae99d1bb4930b7ab8249f..418c0fea8d1d40ae16e4af61c45609e0180ebd28 100644 (file)
                 <span>@icon('tag')</span>
                 <span>{{ trans('entities.tags_view_tags') }}</span>
             </a>
+
+            @if(userCan('content-import'))
+                <a href="{{ url('/import') }}" class="icon-list-item">
+                    <span>@icon('upload')</span>
+                    <span>{{ trans('entities.import') }}</span>
+                </a>
+            @endif
         </div>
     </div>
 
index a55ab56d199cf174b6144f26bf0b7306bfaaa72d..e58c842ba421a324981c8849c989ab5d36b5fdb2 100644 (file)
@@ -18,6 +18,7 @@
         <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }}</span><span>.pdf</span></a></li>
         <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_text') }}</span><span>.txt</span></a></li>
         <li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_md') }}</span><span>.md</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/zip') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_zip') }}</span><span>.zip</span></a></li>
     </ul>
 
 </div>
index c1280cfb2f7934b7c26ccb1d4060a08ea0198521..0cdf4376cc7ea38b07bfbd2ee31fb73590bfbbf8 100644 (file)
@@ -1,3 +1,11 @@
+{{--
+$name - string
+$autofocus - boolean, optional
+$entityTypes - string, optional
+$entityPermission - string, optional
+$selectorEndpoint - string, optional
+$selectorSize - string, optional (compact)
+--}}
 <div class="form-group entity-selector-container">
     <div component="entity-selector"
          refs="entity-selector-popup@selector"
diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php
new file mode 100644 (file)
index 0000000..a28b79b
--- /dev/null
@@ -0,0 +1,88 @@
+@extends('layouts.simple')
+
+@section('body')
+    <div class="container small">
+
+        <main class="card content-wrap auto-height mt-xxl">
+            <h1 class="list-heading">{{ trans('entities.import_continue') }}</h1>
+            <p class="text-muted">{{ trans('entities.import_continue_desc') }}</p>
+
+            @if(session()->has('import_errors'))
+                <div class="mb-m">
+                    <label class="setting-list-label mb-xs text-neg">@icon('warning') {{ trans('entities.import_errors') }}</label>
+                    <p class="mb-xs small">{{ trans('entities.import_errors_desc') }}</p>
+                    @foreach(session()->get('import_errors') ?? [] as $error)
+                        <p class="mb-none text-neg">{{ $error }}</p>
+                    @endforeach
+                    <hr class="mt-m">
+                </div>
+            @endif
+
+            <div class="mb-m">
+                <label class="setting-list-label mb-m">{{ trans('entities.import_details') }}</label>
+                <div class="flex-container-row justify-space-between wrap">
+                    <div>
+                        @include('exports.parts.import-item', ['type' => $import->type, 'model' => $data])
+                    </div>
+                    <div class="text-right text-muted">
+                        <div>{{ trans('entities.import_size', ['size' => $import->getSizeString()]) }}</div>
+                        <div><span title="{{ $import->created_at->toISOString() }}">{{ trans('entities.import_uploaded_at', ['relativeTime' => $import->created_at->diffForHumans()]) }}</span></div>
+                        @if($import->createdBy)
+                            <div>
+                                {{ trans('entities.import_uploaded_by') }}
+                                <a href="{{ $import->createdBy->getProfileUrl() }}">{{ $import->createdBy->name }}</a>
+                            </div>
+                        @endif
+                    </div>
+                </div>
+            </div>
+
+            <form id="import-run-form"
+                  action="{{ $import->getUrl() }}"
+                  method="POST">
+                {{ csrf_field() }}
+
+                @if($import->type === 'page' || $import->type === 'chapter')
+                    <hr>
+                    <label class="setting-list-label">{{ trans('entities.import_location') }}</label>
+                    <p class="small mb-s">{{ trans('entities.import_location_desc') }}</p>
+                    @if($errors->has('parent'))
+                        <div class="mb-s">
+                            @include('form.errors', ['name' => 'parent'])
+                        </div>
+                    @endif
+                    @include('entities.selector', [
+                        'name' => 'parent',
+                        'entityTypes' => $import->type === 'page' ? 'chapter,book' : 'book',
+                        'entityPermission' => "{$import->type}-create",
+                        'selectorSize' => 'compact small',
+                    ])
+                @endif
+
+                <div class="flex-container-row items-center justify-flex-end">
+                    <a href="{{ url('/import') }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <div component="dropdown" class="inline block mx-s">
+                        <button refs="dropdown@toggle"
+                                type="button"
+                                title="{{ trans('common.delete') }}"
+                                class="button outline">{{ trans('common.delete') }}</button>
+                        <div refs="dropdown@menu" class="dropdown-menu">
+                            <p class="text-neg bold small px-m mb-xs">{{ trans('entities.import_delete_confirm') }}</p>
+                            <p class="small px-m mb-xs">{{ trans('entities.import_delete_desc') }}</p>
+                            <button type="submit" form="import-delete-form" class="text-link small text-item">{{ trans('common.confirm') }}</button>
+                        </div>
+                    </div>
+                    <button component="loading-button" type="submit" class="button">{{ trans('entities.import_run') }}</button>
+                </div>
+            </form>
+        </main>
+    </div>
+
+    <form id="import-delete-form"
+          action="{{ $import->getUrl() }}"
+          method="post">
+        {{ method_field('DELETE') }}
+        {{ csrf_field() }}
+    </form>
+
+@stop
diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php
new file mode 100644 (file)
index 0000000..be9de4c
--- /dev/null
@@ -0,0 +1,56 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+
+        <main class="card content-wrap auto-height mt-xxl">
+            <h1 class="list-heading">{{ trans('entities.import') }}</h1>
+            <form action="{{ url('/import') }}" enctype="multipart/form-data" method="POST">
+                {{ csrf_field() }}
+                <div class="flex-container-row justify-space-between wrap gap-x-xl gap-y-s">
+                    <p class="flex min-width-l text-muted mb-s">{{ trans('entities.import_desc') }}</p>
+                    <div class="flex-none min-width-l flex-container-row justify-flex-end">
+                        <div class="mb-m">
+                            <label for="file">{{ trans('entities.import_zip_select') }}</label>
+                            <input type="file"
+                                   accept=".zip,application/zip,application/x-zip-compressed"
+                                   name="file"
+                                   id="file"
+                                   class="custom-simple-file-input">
+                            @include('form.errors', ['name' => 'file'])
+                        </div>
+                    </div>
+                </div>
+
+                @if(count($zipErrors) > 0)
+                    <p class="mb-xs"><strong class="text-neg">{{ trans('entities.import_zip_validation_errors') }}</strong></p>
+                    <ul class="mb-m">
+                        @foreach($zipErrors as $key => $error)
+                            <li><strong class="text-neg">[{{ $key }}]</strong>: {{ $error }}</li>
+                        @endforeach
+                    </ul>
+                @endif
+
+                <div class="text-right">
+                    <a href="{{ url('/books') }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button type="submit" class="button">{{ trans('entities.import_validate') }}</button>
+                </div>
+            </form>
+        </main>
+
+        <main class="card content-wrap auto-height mt-xxl">
+            <h2 class="list-heading">{{ trans('entities.import_pending') }}</h2>
+            @if(count($imports) === 0)
+                <p>{{ trans('entities.import_pending_none') }}</p>
+            @else
+                <div class="item-list my-m">
+                    @foreach($imports as $import)
+                        @include('exports.parts.import', ['import' => $import])
+                    @endforeach
+                </div>
+            @endif
+        </main>
+    </div>
+
+@stop
diff --git a/resources/views/exports/parts/import-item.blade.php b/resources/views/exports/parts/import-item.blade.php
new file mode 100644 (file)
index 0000000..5da4b21
--- /dev/null
@@ -0,0 +1,28 @@
+{{--
+$type - string
+$model - object
+--}}
+<div class="import-item text-{{ $type }} mb-xs">
+    <p class="mb-none">@icon($type){{ $model->name }}</p>
+    <div class="ml-s">
+        <div class="text-muted">
+            @if($model->attachments ?? [])
+                <span>@icon('attach'){{ count($model->attachments) }}</span>
+            @endif
+            @if($model->images ?? [])
+                <span>@icon('image'){{ count($model->images) }}</span>
+            @endif
+            @if($model->tags ?? [])
+                <span>@icon('tag'){{ count($model->tags) }}</span>
+            @endif
+        </div>
+        @if(method_exists($model, 'children'))
+            @foreach($model->children() as $child)
+                @include('exports.parts.import-item', [
+                    'type' => ($child instanceof \BookStack\Exports\ZipExports\Models\ZipExportPage) ? 'page' : 'chapter',
+                    'model' => $child
+                ])
+            @endforeach
+        @endif
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/exports/parts/import.blade.php b/resources/views/exports/parts/import.blade.php
new file mode 100644 (file)
index 0000000..2f7659c
--- /dev/null
@@ -0,0 +1,10 @@
+<div class="item-list-row flex-container-row items-center justify-space-between wrap">
+    <div class="px-m py-s">
+        <a href="{{ $import->getUrl() }}"
+           class="text-{{ $import->type }}">@icon($import->type) {{ $import->name }}</a>
+    </div>
+    <div class="px-m py-s flex-container-row gap-m items-center">
+        <div class="bold opacity-80 text-muted">{{ $import->getSizeString() }}</div>
+        <div class="bold opacity-80 text-muted min-width-xs text-right" title="{{ $import->created_at->toISOString() }}">@icon('time'){{ $import->created_at->diffForHumans() }}</div>
+    </div>
+</div>
\ No newline at end of file
index 03cd4be88f08edbc3e61e1f988036709390e3de5..72d41ee56c74f887551ea857fe2b65d892a2d688 100644 (file)
@@ -1,3 +1,6 @@
+{{--
+$name - string
+--}}
 @if($errors->has($name))
     <div class="text-neg text-small">{{ $errors->first($name) }}</div>
 @endif
\ No newline at end of file
index 9fa76f2bfd7741ebd964e1cb1bff6282e4175500..a77b80e4c696f1388c9d07de5a6d73463b1d85be 100644 (file)
@@ -37,6 +37,7 @@
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div>
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])</div>
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])</div>
+                <div>@include('settings.roles.parts.checkbox', ['permission' => 'content-import', 'label' => trans('settings.role_import_content')])</div>
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'editor-change', 'label' => trans('settings.role_editor_change')])</div>
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'receive-notifications', 'label' => trans('settings.role_notifications')])</div>
             </div>
index c0919d3247ba12eae80dddc7199f9a3ca020e261..71036485597d20d41e8b500aeded1341496d983b 100644 (file)
@@ -9,6 +9,7 @@
 use BookStack\Activity\Controllers\AuditLogApiController;
 use BookStack\Api\ApiDocsController;
 use BookStack\Entities\Controllers as EntityControllers;
+use BookStack\Exports\Controllers as ExportControllers;
 use BookStack\Permissions\ContentPermissionApiController;
 use BookStack\Search\SearchApiController;
 use BookStack\Uploads\Controllers\AttachmentApiController;
@@ -31,21 +32,20 @@ Route::get('books/{id}', [EntityControllers\BookApiController::class, 'read']);
 Route::put('books/{id}', [EntityControllers\BookApiController::class, 'update']);
 Route::delete('books/{id}', [EntityControllers\BookApiController::class, 'delete']);
 
-Route::get('books/{id}/export/html', [EntityControllers\BookExportApiController::class, 'exportHtml']);
-Route::get('books/{id}/export/pdf', [EntityControllers\BookExportApiController::class, 'exportPdf']);
-Route::get('books/{id}/export/plaintext', [EntityControllers\BookExportApiController::class, 'exportPlainText']);
-Route::get('books/{id}/export/markdown', [EntityControllers\BookExportApiController::class, 'exportMarkdown']);
+Route::get('books/{id}/export/html', [ExportControllers\BookExportApiController::class, 'exportHtml']);
+Route::get('books/{id}/export/pdf', [ExportControllers\BookExportApiController::class, 'exportPdf']);
+Route::get('books/{id}/export/plaintext', [ExportControllers\BookExportApiController::class, 'exportPlainText']);
+Route::get('books/{id}/export/markdown', [ExportControllers\BookExportApiController::class, 'exportMarkdown']);
 
 Route::get('chapters', [EntityControllers\ChapterApiController::class, 'list']);
 Route::post('chapters', [EntityControllers\ChapterApiController::class, 'create']);
 Route::get('chapters/{id}', [EntityControllers\ChapterApiController::class, 'read']);
 Route::put('chapters/{id}', [EntityControllers\ChapterApiController::class, 'update']);
 Route::delete('chapters/{id}', [EntityControllers\ChapterApiController::class, 'delete']);
-
-Route::get('chapters/{id}/export/html', [EntityControllers\ChapterExportApiController::class, 'exportHtml']);
-Route::get('chapters/{id}/export/pdf', [EntityControllers\ChapterExportApiController::class, 'exportPdf']);
-Route::get('chapters/{id}/export/plaintext', [EntityControllers\ChapterExportApiController::class, 'exportPlainText']);
-Route::get('chapters/{id}/export/markdown', [EntityControllers\ChapterExportApiController::class, 'exportMarkdown']);
+Route::get('chapters/{id}/export/html', [ExportControllers\ChapterExportApiController::class, 'exportHtml']);
+Route::get('chapters/{id}/export/pdf', [ExportControllers\ChapterExportApiController::class, 'exportPdf']);
+Route::get('chapters/{id}/export/plaintext', [ExportControllers\ChapterExportApiController::class, 'exportPlainText']);
+Route::get('chapters/{id}/export/markdown', [ExportControllers\ChapterExportApiController::class, 'exportMarkdown']);
 
 Route::get('pages', [EntityControllers\PageApiController::class, 'list']);
 Route::post('pages', [EntityControllers\PageApiController::class, 'create']);
@@ -53,10 +53,10 @@ Route::get('pages/{id}', [EntityControllers\PageApiController::class, 'read']);
 Route::put('pages/{id}', [EntityControllers\PageApiController::class, 'update']);
 Route::delete('pages/{id}', [EntityControllers\PageApiController::class, 'delete']);
 
-Route::get('pages/{id}/export/html', [EntityControllers\PageExportApiController::class, 'exportHtml']);
-Route::get('pages/{id}/export/pdf', [EntityControllers\PageExportApiController::class, 'exportPdf']);
-Route::get('pages/{id}/export/plaintext', [EntityControllers\PageExportApiController::class, 'exportPlainText']);
-Route::get('pages/{id}/export/markdown', [EntityControllers\PageExportApiController::class, 'exportMarkdown']);
+Route::get('pages/{id}/export/html', [ExportControllers\PageExportApiController::class, 'exportHtml']);
+Route::get('pages/{id}/export/pdf', [ExportControllers\PageExportApiController::class, 'exportPdf']);
+Route::get('pages/{id}/export/plaintext', [ExportControllers\PageExportApiController::class, 'exportPlainText']);
+Route::get('pages/{id}/export/markdown', [ExportControllers\PageExportApiController::class, 'exportMarkdown']);
 
 Route::get('image-gallery', [ImageGalleryApiController::class, 'list']);
 Route::post('image-gallery', [ImageGalleryApiController::class, 'create']);
index 81b938f32eccbde217f15ded0f9e572935c122bf..85f83352859a8d49b155f93202f786f155e01c47 100644 (file)
@@ -7,6 +7,7 @@ use BookStack\Api\UserApiTokenController;
 use BookStack\App\HomeController;
 use BookStack\App\MetaController;
 use BookStack\Entities\Controllers as EntityControllers;
+use BookStack\Exports\Controllers as ExportControllers;
 use BookStack\Http\Middleware\VerifyCsrfToken;
 use BookStack\Permissions\PermissionsController;
 use BookStack\References\ReferenceController;
@@ -74,11 +75,11 @@ Route::middleware('auth')->group(function () {
     Route::get('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'show']);
     Route::put('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'update']);
     Route::get('/books/{slug}/references', [ReferenceController::class, 'book']);
-    Route::get('/books/{bookSlug}/export/html', [EntityControllers\BookExportController::class, 'html']);
-    Route::get('/books/{bookSlug}/export/pdf', [EntityControllers\BookExportController::class, 'pdf']);
-    Route::get('/books/{bookSlug}/export/markdown', [EntityControllers\BookExportController::class, 'markdown']);
-    Route::get('/books/{bookSlug}/export/zip', [EntityControllers\BookExportController::class, 'zip']);
-    Route::get('/books/{bookSlug}/export/plaintext', [EntityControllers\BookExportController::class, 'plainText']);
+    Route::get('/books/{bookSlug}/export/html', [ExportControllers\BookExportController::class, 'html']);
+    Route::get('/books/{bookSlug}/export/pdf', [ExportControllers\BookExportController::class, 'pdf']);
+    Route::get('/books/{bookSlug}/export/markdown', [ExportControllers\BookExportController::class, 'markdown']);
+    Route::get('/books/{bookSlug}/export/zip', [ExportControllers\BookExportController::class, 'zip']);
+    Route::get('/books/{bookSlug}/export/plaintext', [ExportControllers\BookExportController::class, 'plainText']);
 
     // Pages
     Route::get('/books/{bookSlug}/create-page', [EntityControllers\PageController::class, 'create']);
@@ -86,10 +87,11 @@ Route::middleware('auth')->group(function () {
     Route::get('/books/{bookSlug}/draft/{pageId}', [EntityControllers\PageController::class, 'editDraft']);
     Route::post('/books/{bookSlug}/draft/{pageId}', [EntityControllers\PageController::class, 'store']);
     Route::get('/books/{bookSlug}/page/{pageSlug}', [EntityControllers\PageController::class, 'show']);
-    Route::get('/books/{bookSlug}/page/{pageSlug}/export/pdf', [EntityControllers\PageExportController::class, 'pdf']);
-    Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [EntityControllers\PageExportController::class, 'html']);
-    Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [EntityControllers\PageExportController::class, 'markdown']);
-    Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [EntityControllers\PageExportController::class, 'plainText']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/pdf', [ExportControllers\PageExportController::class, 'pdf']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [ExportControllers\PageExportController::class, 'html']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [ExportControllers\PageExportController::class, 'markdown']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [ExportControllers\PageExportController::class, 'plainText']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/zip', [ExportControllers\PageExportController::class, 'zip']);
     Route::get('/books/{bookSlug}/page/{pageSlug}/edit', [EntityControllers\PageController::class, 'edit']);
     Route::get('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'showMove']);
     Route::put('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'move']);
@@ -126,10 +128,11 @@ Route::middleware('auth')->group(function () {
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/edit', [EntityControllers\ChapterController::class, 'edit']);
     Route::post('/books/{bookSlug}/chapter/{chapterSlug}/convert-to-book', [EntityControllers\ChapterController::class, 'convertToBook']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'showForChapter']);
-    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [EntityControllers\ChapterExportController::class, 'pdf']);
-    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [EntityControllers\ChapterExportController::class, 'html']);
-    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [EntityControllers\ChapterExportController::class, 'markdown']);
-    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [EntityControllers\ChapterExportController::class, 'plainText']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [ExportControllers\ChapterExportController::class, 'pdf']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [ExportControllers\ChapterExportController::class, 'html']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [ExportControllers\ChapterExportController::class, 'markdown']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [ExportControllers\ChapterExportController::class, 'plainText']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/zip', [ExportControllers\ChapterExportController::class, 'zip']);
     Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'updateForChapter']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/references', [ReferenceController::class, 'chapter']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/delete', [EntityControllers\ChapterController::class, 'showDelete']);
@@ -203,6 +206,13 @@ Route::middleware('auth')->group(function () {
     // Watching
     Route::put('/watching/update', [ActivityControllers\WatchController::class, 'update']);
 
+    // Importing
+    Route::get('/import', [ExportControllers\ImportController::class, 'start']);
+    Route::post('/import', [ExportControllers\ImportController::class, 'upload']);
+    Route::get('/import/{id}', [ExportControllers\ImportController::class, 'show']);
+    Route::post('/import/{id}', [ExportControllers\ImportController::class, 'run']);
+    Route::delete('/import/{id}', [ExportControllers\ImportController::class, 'delete']);
+
     // Other Pages
     Route::get('/', [HomeController::class, 'index']);
     Route::get('/home', [HomeController::class, 'index']);
index ef95bc2e8f41fdaad71d30a13352229f0996e20f..9a00c983a50475ee70dbfedc2fe5ce53323555c8 100644 (file)
@@ -603,6 +603,33 @@ class LdapTest extends TestCase
         $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => 'displayNameAttribute']);
     }
 
+    public function test_login_uses_multiple_display_properties_if_defined()
+    {
+        app('config')->set([
+            'services.ldap.display_name_attribute' => 'firstname|middlename|noname|lastname',
+        ]);
+
+        $this->commonLdapMocks(1, 1, 1, 2, 1);
+        $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
+            ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
+            ->andReturn(['count' => 1, 0 => [
+                'uid'         => [$this->mockUser->name],
+                'cn'          => [$this->mockUser->name],
+                'dn'          => 'dc=test' . config('services.ldap.base_dn'),
+                'firstname' => ['Barry'],
+                'middlename' => ['Elliott'],
+                'lastname' => ['Chuckle'],
+                'mail'     => [$this->mockUser->email],
+            ]]);
+
+        $this->mockUserLogin();
+
+        $this->assertDatabaseHas('users', [
+            'email' => $this->mockUser->email,
+            'name' => 'Barry Elliott Chuckle',
+        ]);
+    }
+
     public function test_login_uses_default_display_name_attribute_if_specified_not_present()
     {
         app('config')->set([
index 201f67b533338057e13b405d611d21cd2c1b1ba6..205f75a4d62c159c2fefde68712567981c73c693 100644 (file)
@@ -787,6 +787,20 @@ class OidcTest extends TestCase
         $this->assertTrue($user->hasRole($roleA->id));
     }
 
+    public function test_userinfo_endpoint_response_with_complex_json_content_type_handled()
+    {
+        $userinfoResponseData = [
+            'sub' => OidcJwtHelper::defaultPayload()['sub'],
+            'name' => 'Barry',
+        ];
+        $userinfoResponse = new Response(200, ['Content-Type'  => 'Application/Json ; charset=utf-8'], json_encode($userinfoResponseData));
+        $resp = $this->runLogin(['name' => null], [$userinfoResponse]);
+        $resp->assertRedirect('/');
+
+        $user = User::where('email', OidcJwtHelper::defaultPayload()['email'])->first();
+        $this->assertEquals('Barry', $user->name);
+    }
+
     public function test_userinfo_endpoint_jwks_response_handled()
     {
         $userinfoResponseData = OidcJwtHelper::idToken(['name' => 'Barry Jwks']);
diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php
deleted file mode 100644 (file)
index 97b1ff1..0000000
+++ /dev/null
@@ -1,581 +0,0 @@
-<?php
-
-namespace Tests\Entity;
-
-use BookStack\Entities\Models\Book;
-use BookStack\Entities\Models\Chapter;
-use BookStack\Entities\Models\Page;
-use BookStack\Entities\Tools\PdfGenerator;
-use BookStack\Exceptions\PdfExportException;
-use Illuminate\Support\Facades\Storage;
-use Tests\TestCase;
-
-class ExportTest extends TestCase
-{
-    public function test_page_text_export()
-    {
-        $page = $this->entities->page();
-        $this->asEditor();
-
-        $resp = $this->get($page->getUrl('/export/plaintext'));
-        $resp->assertStatus(200);
-        $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
-    }
-
-    public function test_page_pdf_export()
-    {
-        $page = $this->entities->page();
-        $this->asEditor();
-
-        $resp = $this->get($page->getUrl('/export/pdf'));
-        $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
-    }
-
-    public function test_page_html_export()
-    {
-        $page = $this->entities->page();
-        $this->asEditor();
-
-        $resp = $this->get($page->getUrl('/export/html'));
-        $resp->assertStatus(200);
-        $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
-    }
-
-    public function test_book_text_export()
-    {
-        $book = $this->entities->bookHasChaptersAndPages();
-        $directPage = $book->directPages()->first();
-        $chapter = $book->chapters()->first();
-        $chapterPage = $chapter->pages()->first();
-        $this->entities->updatePage($directPage, ['html' => '<p>My awesome page</p>']);
-        $this->entities->updatePage($chapterPage, ['html' => '<p>My little nested page</p>']);
-        $this->asEditor();
-
-        $resp = $this->get($book->getUrl('/export/plaintext'));
-        $resp->assertStatus(200);
-        $resp->assertSee($book->name);
-        $resp->assertSee($chapterPage->name);
-        $resp->assertSee($chapter->name);
-        $resp->assertSee($directPage->name);
-        $resp->assertSee('My awesome page');
-        $resp->assertSee('My little nested page');
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"');
-    }
-
-    public function test_book_text_export_format()
-    {
-        $entities = $this->entities->createChainBelongingToUser($this->users->viewer());
-        $this->entities->updatePage($entities['page'], ['html' => '<p>My great page</p><p>Full of <strong>great</strong> stuff</p>', 'name' => 'My wonderful page!']);
-        $entities['chapter']->name = 'Export chapter';
-        $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within";
-        $entities['book']->name = 'Export Book';
-        $entities['book']->description = "This is a book with stuff to export";
-        $entities['chapter']->save();
-        $entities['book']->save();
-
-        $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext'));
-
-        $expected = "Export Book\nThis is a book with stuff to export\n\nExport chapter\nA test chapter to be exported\nIt has loads of info within\n\n";
-        $expected .= "My wonderful page!\nMy great page Full of great stuff";
-        $resp->assertSee($expected);
-    }
-
-    public function test_book_pdf_export()
-    {
-        $page = $this->entities->page();
-        $book = $page->book;
-        $this->asEditor();
-
-        $resp = $this->get($book->getUrl('/export/pdf'));
-        $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"');
-    }
-
-    public function test_book_html_export()
-    {
-        $page = $this->entities->page();
-        $book = $page->book;
-        $this->asEditor();
-
-        $resp = $this->get($book->getUrl('/export/html'));
-        $resp->assertStatus(200);
-        $resp->assertSee($book->name);
-        $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
-    }
-
-    public function test_book_html_export_shows_html_descriptions()
-    {
-        $book = $this->entities->bookHasChaptersAndPages();
-        $chapter = $book->chapters()->first();
-        $book->description_html = '<p>A description with <strong>HTML</strong> within!</p>';
-        $chapter->description_html = '<p>A chapter description with <strong>HTML</strong> within!</p>';
-        $book->save();
-        $chapter->save();
-
-        $resp = $this->asEditor()->get($book->getUrl('/export/html'));
-        $resp->assertSee($book->description_html, false);
-        $resp->assertSee($chapter->description_html, false);
-    }
-
-    public function test_chapter_text_export()
-    {
-        $chapter = $this->entities->chapter();
-        $page = $chapter->pages[0];
-        $this->entities->updatePage($page, ['html' => '<p>This is content within the page!</p>']);
-        $this->asEditor();
-
-        $resp = $this->get($chapter->getUrl('/export/plaintext'));
-        $resp->assertStatus(200);
-        $resp->assertSee($chapter->name);
-        $resp->assertSee($page->name);
-        $resp->assertSee('This is content within the page!');
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"');
-    }
-
-    public function test_chapter_text_export_format()
-    {
-        $entities = $this->entities->createChainBelongingToUser($this->users->viewer());
-        $this->entities->updatePage($entities['page'], ['html' => '<p>My great page</p><p>Full of <strong>great</strong> stuff</p>', 'name' => 'My wonderful page!']);
-        $entities['chapter']->name = 'Export chapter';
-        $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within";
-        $entities['chapter']->save();
-
-        $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext'));
-
-        $expected = "Export chapter\nA test chapter to be exported\nIt has loads of info within\n\n";
-        $expected .= "My wonderful page!\nMy great page Full of great stuff";
-        $resp->assertSee($expected);
-    }
-
-    public function test_chapter_pdf_export()
-    {
-        $chapter = $this->entities->chapter();
-        $this->asEditor();
-
-        $resp = $this->get($chapter->getUrl('/export/pdf'));
-        $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
-    }
-
-    public function test_chapter_html_export()
-    {
-        $chapter = $this->entities->chapter();
-        $page = $chapter->pages[0];
-        $this->asEditor();
-
-        $resp = $this->get($chapter->getUrl('/export/html'));
-        $resp->assertStatus(200);
-        $resp->assertSee($chapter->name);
-        $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
-    }
-
-    public function test_chapter_html_export_shows_html_descriptions()
-    {
-        $chapter = $this->entities->chapter();
-        $chapter->description_html = '<p>A description with <strong>HTML</strong> within!</p>';
-        $chapter->save();
-
-        $resp = $this->asEditor()->get($chapter->getUrl('/export/html'));
-        $resp->assertSee($chapter->description_html, false);
-    }
-
-    public function test_page_html_export_contains_custom_head_if_set()
-    {
-        $page = $this->entities->page();
-
-        $customHeadContent = '<style>p{color: red;}</style>';
-        $this->setSettings(['app-custom-head' => $customHeadContent]);
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertSee($customHeadContent, false);
-    }
-
-    public function test_page_html_export_does_not_break_with_only_comments_in_custom_head()
-    {
-        $page = $this->entities->page();
-
-        $customHeadContent = '<!-- A comment -->';
-        $this->setSettings(['app-custom-head' => $customHeadContent]);
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertStatus(200);
-        $resp->assertSee($customHeadContent, false);
-    }
-
-    public function test_page_html_export_use_absolute_dates()
-    {
-        $page = $this->entities->page();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss'));
-        $resp->assertDontSee($page->created_at->diffForHumans());
-        $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss'));
-        $resp->assertDontSee($page->updated_at->diffForHumans());
-    }
-
-    public function test_page_export_does_not_include_user_or_revision_links()
-    {
-        $page = $this->entities->page();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertDontSee($page->getUrl('/revisions'));
-        $resp->assertDontSee($page->createdBy->getProfileUrl());
-        $resp->assertSee($page->createdBy->name);
-    }
-
-    public function test_page_export_sets_right_data_type_for_svg_embeds()
-    {
-        $page = $this->entities->page();
-        Storage::disk('local')->makeDirectory('uploads/images/gallery');
-        Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
-        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg">';
-        $page->save();
-
-        $this->asEditor();
-        $resp = $this->get($page->getUrl('/export/html'));
-        Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
-
-        $resp->assertStatus(200);
-        $resp->assertSee('<img src="data:image/svg+xml;base64', false);
-    }
-
-    public function test_page_image_containment_works_on_multiple_images_within_a_single_line()
-    {
-        $page = $this->entities->page();
-        Storage::disk('local')->makeDirectory('uploads/images/gallery');
-        Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
-        Storage::disk('local')->put('uploads/images/gallery/svg_test2.svg', '<svg></svg>');
-        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg" class="a"><img src="http://localhost/uploads/images/gallery/svg_test2.svg" class="b">';
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
-        Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg');
-
-        $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test');
-    }
-
-    public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder()
-    {
-        $page = $this->entities->page();
-        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg"/>'
-            . '<img src="http://localhost/uploads/svg_test.svg"/>'
-            . '<img src="/uploads/svg_test.svg"/>';
-        $storageDisk = Storage::disk('local');
-        $storageDisk->makeDirectory('uploads/images/gallery');
-        $storageDisk->put('uploads/images/gallery/svg_test.svg', '<svg>good</svg>');
-        $storageDisk->put('uploads/svg_test.svg', '<svg>bad</svg>');
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-
-        $storageDisk->delete('uploads/images/gallery/svg_test.svg');
-        $storageDisk->delete('uploads/svg_test.svg');
-
-        $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg', false);
-        $resp->assertSee('http://localhost/uploads/svg_test.svg');
-        $resp->assertSee('src="/uploads/svg_test.svg"', false);
-    }
-
-    public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local()
-    {
-        $contents = file_get_contents(public_path('.htaccess'));
-        config()->set('filesystems.images', 'local');
-
-        $page = $this->entities->page();
-        $page->html = '<img src="http://localhost/uploads/images/../../.htaccess"/>';
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertDontSee(base64_encode($contents));
-    }
-
-    public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local_secure()
-    {
-        $testFilePath = storage_path('logs/test.txt');
-        config()->set('filesystems.images', 'local_secure');
-        file_put_contents($testFilePath, 'I am a cat');
-
-        $page = $this->entities->page();
-        $page->html = '<img src="http://localhost/uploads/images/../../logs/test.txt"/>';
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertDontSee(base64_encode('I am a cat'));
-        unlink($testFilePath);
-    }
-
-    public function test_exports_removes_scripts_from_custom_head()
-    {
-        $entities = [
-            Page::query()->first(), Chapter::query()->first(), Book::query()->first(),
-        ];
-        setting()->put('app-custom-head', '<script>window.donkey = "cat";</script><style>.my-test-class { color: red; }</style>');
-
-        foreach ($entities as $entity) {
-            $resp = $this->asEditor()->get($entity->getUrl('/export/html'));
-            $resp->assertDontSee('window.donkey');
-            $resp->assertDontSee('<script', false);
-            $resp->assertSee('.my-test-class { color: red; }');
-        }
-    }
-
-    public function test_page_export_with_deleted_creator_and_updater()
-    {
-        $user = $this->users->viewer(['name' => 'ExportWizardTheFifth']);
-        $page = $this->entities->page();
-        $page->created_by = $user->id;
-        $page->updated_by = $user->id;
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertSee('ExportWizardTheFifth');
-
-        $user->delete();
-        $resp = $this->get($page->getUrl('/export/html'));
-        $resp->assertStatus(200);
-        $resp->assertDontSee('ExportWizardTheFifth');
-    }
-
-    public function test_page_pdf_export_converts_iframes_to_links()
-    {
-        $page = Page::query()->first()->forceFill([
-            'html'     => '<iframe width="560" height="315" src="//www.youtube.com/embed/ShqUjt33uOs"></iframe>',
-        ]);
-        $page->save();
-
-        $pdfHtml = '';
-        $mockPdfGenerator = $this->mock(PdfGenerator::class);
-        $mockPdfGenerator->shouldReceive('fromHtml')
-            ->with(\Mockery::capture($pdfHtml))
-            ->andReturn('');
-        $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF);
-
-        $this->asEditor()->get($page->getUrl('/export/pdf'));
-        $this->assertStringNotContainsString('iframe>', $pdfHtml);
-        $this->assertStringContainsString('<p><a href="https://www.youtube.com/embed/ShqUjt33uOs">https://www.youtube.com/embed/ShqUjt33uOs</a></p>', $pdfHtml);
-    }
-
-    public function test_page_pdf_export_opens_details_blocks()
-    {
-        $page = $this->entities->page()->forceFill([
-            'html'     => '<details><summary>Hello</summary><p>Content!</p></details>',
-        ]);
-        $page->save();
-
-        $pdfHtml = '';
-        $mockPdfGenerator = $this->mock(PdfGenerator::class);
-        $mockPdfGenerator->shouldReceive('fromHtml')
-            ->with(\Mockery::capture($pdfHtml))
-            ->andReturn('');
-        $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF);
-
-        $this->asEditor()->get($page->getUrl('/export/pdf'));
-        $this->assertStringContainsString('<details open="open"', $pdfHtml);
-    }
-
-    public function test_page_markdown_export()
-    {
-        $page = $this->entities->page();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
-        $resp->assertStatus(200);
-        $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
-    }
-
-    public function test_page_markdown_export_uses_existing_markdown_if_apparent()
-    {
-        $page = $this->entities->page()->forceFill([
-            'markdown' => '# A header',
-            'html'     => '<h1>Dogcat</h1>',
-        ]);
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
-        $resp->assertSee('A header');
-        $resp->assertDontSee('Dogcat');
-    }
-
-    public function test_page_markdown_export_converts_html_where_no_markdown()
-    {
-        $page = $this->entities->page()->forceFill([
-            'markdown' => '',
-            'html'     => '<h1>Dogcat</h1><p>Some <strong>bold</strong> text</p>',
-        ]);
-        $page->save();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
-        $resp->assertSee("# Dogcat\n\nSome **bold** text");
-    }
-
-    public function test_chapter_markdown_export()
-    {
-        $chapter = $this->entities->chapter();
-        $chapter->description_html = '<p>My <strong>chapter</strong> description</p>';
-        $chapter->save();
-        $page = $chapter->pages()->first();
-
-        $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown'));
-
-        $resp->assertSee('# ' . $chapter->name);
-        $resp->assertSee('# ' . $page->name);
-        $resp->assertSee('My **chapter** description');
-    }
-
-    public function test_book_markdown_export()
-    {
-        $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
-        $book->description_html = '<p>My <strong>book</strong> description</p>';
-        $book->save();
-
-        $chapter = $book->chapters()->first();
-        $chapter->description_html = '<p>My <strong>chapter</strong> description</p>';
-        $chapter->save();
-
-        $page = $chapter->pages()->first();
-        $resp = $this->asEditor()->get($book->getUrl('/export/markdown'));
-
-        $resp->assertSee('# ' . $book->name);
-        $resp->assertSee('# ' . $chapter->name);
-        $resp->assertSee('# ' . $page->name);
-        $resp->assertSee('My **book** description');
-        $resp->assertSee('My **chapter** description');
-    }
-
-    public function test_book_markdown_export_concats_immediate_pages_with_newlines()
-    {
-        /** @var Book $book */
-        $book = Book::query()->whereHas('pages')->first();
-
-        $this->asEditor()->get($book->getUrl('/create-page'));
-        $this->get($book->getUrl('/create-page'));
-
-        [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get();
-        $pageA->html = '<p>hello tester</p>';
-        $pageA->save();
-        $pageB->name = 'The second page in this test';
-        $pageB->save();
-
-        $resp = $this->get($book->getUrl('/export/markdown'));
-        $resp->assertDontSee('hello tester# The second page in this test');
-        $resp->assertSee("hello tester\n\n# The second page in this test");
-    }
-
-    public function test_export_option_only_visible_and_accessible_with_permission()
-    {
-        $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
-        $chapter = $book->chapters()->first();
-        $page = $chapter->pages()->first();
-        $entities = [$book, $chapter, $page];
-        $user = $this->users->viewer();
-        $this->actingAs($user);
-
-        foreach ($entities as $entity) {
-            $resp = $this->get($entity->getUrl());
-            $resp->assertSee('/export/pdf');
-        }
-
-        $this->permissions->removeUserRolePermissions($user, ['content-export']);
-
-        foreach ($entities as $entity) {
-            $resp = $this->get($entity->getUrl());
-            $resp->assertDontSee('/export/pdf');
-            $resp = $this->get($entity->getUrl('/export/pdf'));
-            $this->assertPermissionError($resp);
-        }
-    }
-
-    public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true()
-    {
-        $page = $this->entities->page();
-
-        config()->set('exports.snappy.pdf_binary', '/abc123');
-        config()->set('app.allow_untrusted_server_fetching', false);
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
-        $resp->assertStatus(200); // Sucessful response with invalid snappy binary indicates dompdf usage.
-
-        config()->set('app.allow_untrusted_server_fetching', true);
-        $resp = $this->get($page->getUrl('/export/pdf'));
-        $resp->assertStatus(500); // Bad response indicates wkhtml usage
-    }
-
-    public function test_pdf_command_option_used_if_set()
-    {
-        $page = $this->entities->page();
-        $command = 'cp {input_html_path} {output_pdf_path}';
-        config()->set('exports.pdf_command', $command);
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
-        $download = $resp->getContent();
-
-        $this->assertStringContainsString(e($page->name), $download);
-        $this->assertStringContainsString('<html lang=', $download);
-    }
-
-    public function test_pdf_command_option_errors_if_output_path_not_written_to()
-    {
-        $page = $this->entities->page();
-        $command = 'echo "hi"';
-        config()->set('exports.pdf_command', $command);
-
-        $this->assertThrows(function () use ($page) {
-            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
-        }, PdfExportException::class);
-    }
-
-    public function test_pdf_command_option_errors_if_command_returns_error_status()
-    {
-        $page = $this->entities->page();
-        $command = 'exit 1';
-        config()->set('exports.pdf_command', $command);
-
-        $this->assertThrows(function () use ($page) {
-            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
-        }, PdfExportException::class);
-    }
-
-    public function test_pdf_command_timout_option_limits_export_time()
-    {
-        $page = $this->entities->page();
-        $command = 'php -r \'sleep(4);\'';
-        config()->set('exports.pdf_command', $command);
-        config()->set('exports.pdf_command_timeout', 1);
-
-        $this->assertThrows(function () use ($page) {
-            $start = time();
-            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
-
-            $this->assertTrue(time() < ($start + 3));
-        }, PdfExportException::class,
-            "PDF Export via command failed due to timeout at 1 second(s)");
-    }
-
-    public function test_html_exports_contain_csp_meta_tag()
-    {
-        $entities = [
-            $this->entities->page(),
-            $this->entities->book(),
-            $this->entities->chapter(),
-        ];
-
-        foreach ($entities as $entity) {
-            $resp = $this->asEditor()->get($entity->getUrl('/export/html'));
-            $this->withHtml($resp)->assertElementExists('head meta[http-equiv="Content-Security-Policy"][content*="script-src "]');
-        }
-    }
-
-    public function test_html_exports_contain_body_classes_for_export_identification()
-    {
-        $page = $this->entities->page();
-
-        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $this->withHtml($resp)->assertElementExists('body.export.export-format-html.export-engine-none');
-    }
-}
diff --git a/tests/Exports/ExportUiTest.php b/tests/Exports/ExportUiTest.php
new file mode 100644 (file)
index 0000000..77b26ad
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Entities\Models\Book;
+use Tests\TestCase;
+
+class ExportUiTest extends TestCase
+{
+    public function test_export_option_only_visible_and_accessible_with_permission()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
+        $chapter = $book->chapters()->first();
+        $page = $chapter->pages()->first();
+        $entities = [$book, $chapter, $page];
+        $user = $this->users->viewer();
+        $this->actingAs($user);
+
+        foreach ($entities as $entity) {
+            $resp = $this->get($entity->getUrl());
+            $resp->assertSee('/export/pdf');
+        }
+
+        $this->permissions->removeUserRolePermissions($user, ['content-export']);
+
+        foreach ($entities as $entity) {
+            $resp = $this->get($entity->getUrl());
+            $resp->assertDontSee('/export/pdf');
+            $resp = $this->get($entity->getUrl('/export/pdf'));
+            $this->assertPermissionError($resp);
+        }
+    }
+}
diff --git a/tests/Exports/HtmlExportTest.php b/tests/Exports/HtmlExportTest.php
new file mode 100644 (file)
index 0000000..069cf28
--- /dev/null
@@ -0,0 +1,253 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class HtmlExportTest extends TestCase
+{
+    public function test_page_html_export()
+    {
+        $page = $this->entities->page();
+        $this->asEditor();
+
+        $resp = $this->get($page->getUrl('/export/html'));
+        $resp->assertStatus(200);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
+    }
+
+    public function test_book_html_export()
+    {
+        $page = $this->entities->page();
+        $book = $page->book;
+        $this->asEditor();
+
+        $resp = $this->get($book->getUrl('/export/html'));
+        $resp->assertStatus(200);
+        $resp->assertSee($book->name);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
+    }
+
+    public function test_book_html_export_shows_html_descriptions()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $chapter = $book->chapters()->first();
+        $book->description_html = '<p>A description with <strong>HTML</strong> within!</p>';
+        $chapter->description_html = '<p>A chapter description with <strong>HTML</strong> within!</p>';
+        $book->save();
+        $chapter->save();
+
+        $resp = $this->asEditor()->get($book->getUrl('/export/html'));
+        $resp->assertSee($book->description_html, false);
+        $resp->assertSee($chapter->description_html, false);
+    }
+
+    public function test_chapter_html_export()
+    {
+        $chapter = $this->entities->chapter();
+        $page = $chapter->pages[0];
+        $this->asEditor();
+
+        $resp = $this->get($chapter->getUrl('/export/html'));
+        $resp->assertStatus(200);
+        $resp->assertSee($chapter->name);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
+    }
+
+    public function test_chapter_html_export_shows_html_descriptions()
+    {
+        $chapter = $this->entities->chapter();
+        $chapter->description_html = '<p>A description with <strong>HTML</strong> within!</p>';
+        $chapter->save();
+
+        $resp = $this->asEditor()->get($chapter->getUrl('/export/html'));
+        $resp->assertSee($chapter->description_html, false);
+    }
+
+    public function test_page_html_export_contains_custom_head_if_set()
+    {
+        $page = $this->entities->page();
+
+        $customHeadContent = '<style>p{color: red;}</style>';
+        $this->setSettings(['app-custom-head' => $customHeadContent]);
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertSee($customHeadContent, false);
+    }
+
+    public function test_page_html_export_does_not_break_with_only_comments_in_custom_head()
+    {
+        $page = $this->entities->page();
+
+        $customHeadContent = '<!-- A comment -->';
+        $this->setSettings(['app-custom-head' => $customHeadContent]);
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertStatus(200);
+        $resp->assertSee($customHeadContent, false);
+    }
+
+    public function test_page_html_export_use_absolute_dates()
+    {
+        $page = $this->entities->page();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss'));
+        $resp->assertDontSee($page->created_at->diffForHumans());
+        $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss'));
+        $resp->assertDontSee($page->updated_at->diffForHumans());
+    }
+
+    public function test_page_export_does_not_include_user_or_revision_links()
+    {
+        $page = $this->entities->page();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertDontSee($page->getUrl('/revisions'));
+        $resp->assertDontSee($page->createdBy->getProfileUrl());
+        $resp->assertSee($page->createdBy->name);
+    }
+
+    public function test_page_export_sets_right_data_type_for_svg_embeds()
+    {
+        $page = $this->entities->page();
+        Storage::disk('local')->makeDirectory('uploads/images/gallery');
+        Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
+        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg">';
+        $page->save();
+
+        $this->asEditor();
+        $resp = $this->get($page->getUrl('/export/html'));
+        Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
+
+        $resp->assertStatus(200);
+        $resp->assertSee('<img src="data:image/svg+xml;base64', false);
+    }
+
+    public function test_page_image_containment_works_on_multiple_images_within_a_single_line()
+    {
+        $page = $this->entities->page();
+        Storage::disk('local')->makeDirectory('uploads/images/gallery');
+        Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
+        Storage::disk('local')->put('uploads/images/gallery/svg_test2.svg', '<svg></svg>');
+        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg" class="a"><img src="http://localhost/uploads/images/gallery/svg_test2.svg" class="b">';
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
+        Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg');
+
+        $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test');
+    }
+
+    public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder()
+    {
+        $page = $this->entities->page();
+        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg"/>'
+            . '<img src="http://localhost/uploads/svg_test.svg"/>'
+            . '<img src="/uploads/svg_test.svg"/>';
+        $storageDisk = Storage::disk('local');
+        $storageDisk->makeDirectory('uploads/images/gallery');
+        $storageDisk->put('uploads/images/gallery/svg_test.svg', '<svg>good</svg>');
+        $storageDisk->put('uploads/svg_test.svg', '<svg>bad</svg>');
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+
+        $storageDisk->delete('uploads/images/gallery/svg_test.svg');
+        $storageDisk->delete('uploads/svg_test.svg');
+
+        $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg', false);
+        $resp->assertSee('http://localhost/uploads/svg_test.svg');
+        $resp->assertSee('src="/uploads/svg_test.svg"', false);
+    }
+
+    public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local()
+    {
+        $contents = file_get_contents(public_path('.htaccess'));
+        config()->set('filesystems.images', 'local');
+
+        $page = $this->entities->page();
+        $page->html = '<img src="http://localhost/uploads/images/../../.htaccess"/>';
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertDontSee(base64_encode($contents));
+    }
+
+    public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local_secure()
+    {
+        $testFilePath = storage_path('logs/test.txt');
+        config()->set('filesystems.images', 'local_secure');
+        file_put_contents($testFilePath, 'I am a cat');
+
+        $page = $this->entities->page();
+        $page->html = '<img src="http://localhost/uploads/images/../../logs/test.txt"/>';
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertDontSee(base64_encode('I am a cat'));
+        unlink($testFilePath);
+    }
+
+    public function test_exports_removes_scripts_from_custom_head()
+    {
+        $entities = [
+            Page::query()->first(), Chapter::query()->first(), Book::query()->first(),
+        ];
+        setting()->put('app-custom-head', '<script>window.donkey = "cat";</script><style>.my-test-class { color: red; }</style>');
+
+        foreach ($entities as $entity) {
+            $resp = $this->asEditor()->get($entity->getUrl('/export/html'));
+            $resp->assertDontSee('window.donkey');
+            $resp->assertDontSee('<script', false);
+            $resp->assertSee('.my-test-class { color: red; }');
+        }
+    }
+
+    public function test_page_export_with_deleted_creator_and_updater()
+    {
+        $user = $this->users->viewer(['name' => 'ExportWizardTheFifth']);
+        $page = $this->entities->page();
+        $page->created_by = $user->id;
+        $page->updated_by = $user->id;
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertSee('ExportWizardTheFifth');
+
+        $user->delete();
+        $resp = $this->get($page->getUrl('/export/html'));
+        $resp->assertStatus(200);
+        $resp->assertDontSee('ExportWizardTheFifth');
+    }
+
+    public function test_html_exports_contain_csp_meta_tag()
+    {
+        $entities = [
+            $this->entities->page(),
+            $this->entities->book(),
+            $this->entities->chapter(),
+        ];
+
+        foreach ($entities as $entity) {
+            $resp = $this->asEditor()->get($entity->getUrl('/export/html'));
+            $this->withHtml($resp)->assertElementExists('head meta[http-equiv="Content-Security-Policy"][content*="script-src "]');
+        }
+    }
+
+    public function test_html_exports_contain_body_classes_for_export_identification()
+    {
+        $page = $this->entities->page();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $this->withHtml($resp)->assertElementExists('body.export.export-format-html.export-engine-none');
+    }
+}
diff --git a/tests/Exports/MarkdownExportTest.php b/tests/Exports/MarkdownExportTest.php
new file mode 100644 (file)
index 0000000..3bccd46
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Entities\Models\Book;
+use Tests\TestCase;
+
+class MarkdownExportTest extends TestCase
+{
+    public function test_page_markdown_export()
+    {
+        $page = $this->entities->page();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
+        $resp->assertStatus(200);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
+    }
+
+    public function test_page_markdown_export_uses_existing_markdown_if_apparent()
+    {
+        $page = $this->entities->page()->forceFill([
+            'markdown' => '# A header',
+            'html'     => '<h1>Dogcat</h1>',
+        ]);
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
+        $resp->assertSee('A header');
+        $resp->assertDontSee('Dogcat');
+    }
+
+    public function test_page_markdown_export_converts_html_where_no_markdown()
+    {
+        $page = $this->entities->page()->forceFill([
+            'markdown' => '',
+            'html'     => '<h1>Dogcat</h1><p>Some <strong>bold</strong> text</p>',
+        ]);
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
+        $resp->assertSee("# Dogcat\n\nSome **bold** text");
+    }
+
+    public function test_chapter_markdown_export()
+    {
+        $chapter = $this->entities->chapter();
+        $chapter->description_html = '<p>My <strong>chapter</strong> description</p>';
+        $chapter->save();
+        $page = $chapter->pages()->first();
+
+        $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown'));
+
+        $resp->assertSee('# ' . $chapter->name);
+        $resp->assertSee('# ' . $page->name);
+        $resp->assertSee('My **chapter** description');
+    }
+
+    public function test_book_markdown_export()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
+        $book->description_html = '<p>My <strong>book</strong> description</p>';
+        $book->save();
+
+        $chapter = $book->chapters()->first();
+        $chapter->description_html = '<p>My <strong>chapter</strong> description</p>';
+        $chapter->save();
+
+        $page = $chapter->pages()->first();
+        $resp = $this->asEditor()->get($book->getUrl('/export/markdown'));
+
+        $resp->assertSee('# ' . $book->name);
+        $resp->assertSee('# ' . $chapter->name);
+        $resp->assertSee('# ' . $page->name);
+        $resp->assertSee('My **book** description');
+        $resp->assertSee('My **chapter** description');
+    }
+
+    public function test_book_markdown_export_concats_immediate_pages_with_newlines()
+    {
+        /** @var Book $book */
+        $book = Book::query()->whereHas('pages')->first();
+
+        $this->asEditor()->get($book->getUrl('/create-page'));
+        $this->get($book->getUrl('/create-page'));
+
+        [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get();
+        $pageA->html = '<p>hello tester</p>';
+        $pageA->save();
+        $pageB->name = 'The second page in this test';
+        $pageB->save();
+
+        $resp = $this->get($book->getUrl('/export/markdown'));
+        $resp->assertDontSee('hello tester# The second page in this test');
+        $resp->assertSee("hello tester\n\n# The second page in this test");
+    }
+}
diff --git a/tests/Exports/PdfExportTest.php b/tests/Exports/PdfExportTest.php
new file mode 100644 (file)
index 0000000..9d85c69
--- /dev/null
@@ -0,0 +1,146 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Exceptions\PdfExportException;
+use BookStack\Exports\PdfGenerator;
+use Tests\TestCase;
+
+class PdfExportTest extends TestCase
+{
+    public function test_page_pdf_export()
+    {
+        $page = $this->entities->page();
+        $this->asEditor();
+
+        $resp = $this->get($page->getUrl('/export/pdf'));
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
+    }
+
+    public function test_book_pdf_export()
+    {
+        $page = $this->entities->page();
+        $book = $page->book;
+        $this->asEditor();
+
+        $resp = $this->get($book->getUrl('/export/pdf'));
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"');
+    }
+
+    public function test_chapter_pdf_export()
+    {
+        $chapter = $this->entities->chapter();
+        $this->asEditor();
+
+        $resp = $this->get($chapter->getUrl('/export/pdf'));
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
+    }
+
+
+    public function test_page_pdf_export_converts_iframes_to_links()
+    {
+        $page = Page::query()->first()->forceFill([
+            'html'     => '<iframe width="560" height="315" src="//www.youtube.com/embed/ShqUjt33uOs"></iframe>',
+        ]);
+        $page->save();
+
+        $pdfHtml = '';
+        $mockPdfGenerator = $this->mock(PdfGenerator::class);
+        $mockPdfGenerator->shouldReceive('fromHtml')
+            ->with(\Mockery::capture($pdfHtml))
+            ->andReturn('');
+        $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF);
+
+        $this->asEditor()->get($page->getUrl('/export/pdf'));
+        $this->assertStringNotContainsString('iframe>', $pdfHtml);
+        $this->assertStringContainsString('<p><a href="https://www.youtube.com/embed/ShqUjt33uOs">https://www.youtube.com/embed/ShqUjt33uOs</a></p>', $pdfHtml);
+    }
+
+    public function test_page_pdf_export_opens_details_blocks()
+    {
+        $page = $this->entities->page()->forceFill([
+            'html'     => '<details><summary>Hello</summary><p>Content!</p></details>',
+        ]);
+        $page->save();
+
+        $pdfHtml = '';
+        $mockPdfGenerator = $this->mock(PdfGenerator::class);
+        $mockPdfGenerator->shouldReceive('fromHtml')
+            ->with(\Mockery::capture($pdfHtml))
+            ->andReturn('');
+        $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF);
+
+        $this->asEditor()->get($page->getUrl('/export/pdf'));
+        $this->assertStringContainsString('<details open="open"', $pdfHtml);
+    }
+
+    public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true()
+    {
+        $page = $this->entities->page();
+
+        config()->set('exports.snappy.pdf_binary', '/abc123');
+        config()->set('app.allow_untrusted_server_fetching', false);
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
+        $resp->assertStatus(200); // Sucessful response with invalid snappy binary indicates dompdf usage.
+
+        config()->set('app.allow_untrusted_server_fetching', true);
+        $resp = $this->get($page->getUrl('/export/pdf'));
+        $resp->assertStatus(500); // Bad response indicates wkhtml usage
+    }
+
+    public function test_pdf_command_option_used_if_set()
+    {
+        $page = $this->entities->page();
+        $command = 'cp {input_html_path} {output_pdf_path}';
+        config()->set('exports.pdf_command', $command);
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
+        $download = $resp->getContent();
+
+        $this->assertStringContainsString(e($page->name), $download);
+        $this->assertStringContainsString('<html lang=', $download);
+    }
+
+    public function test_pdf_command_option_errors_if_output_path_not_written_to()
+    {
+        $page = $this->entities->page();
+        $command = 'echo "hi"';
+        config()->set('exports.pdf_command', $command);
+
+        $this->assertThrows(function () use ($page) {
+            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
+        }, PdfExportException::class);
+    }
+
+    public function test_pdf_command_option_errors_if_command_returns_error_status()
+    {
+        $page = $this->entities->page();
+        $command = 'exit 1';
+        config()->set('exports.pdf_command', $command);
+
+        $this->assertThrows(function () use ($page) {
+            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
+        }, PdfExportException::class);
+    }
+
+    public function test_pdf_command_timout_option_limits_export_time()
+    {
+        $page = $this->entities->page();
+        $command = 'php -r \'sleep(4);\'';
+        config()->set('exports.pdf_command', $command);
+        config()->set('exports.pdf_command_timeout', 1);
+
+        $this->assertThrows(function () use ($page) {
+            $start = time();
+            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
+
+            $this->assertTrue(time() < ($start + 3));
+        }, PdfExportException::class,
+            "PDF Export via command failed due to timeout at 1 second(s)");
+    }
+}
diff --git a/tests/Exports/TextExportTest.php b/tests/Exports/TextExportTest.php
new file mode 100644 (file)
index 0000000..c593a65
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+
+namespace Tests\Exports;
+
+use Tests\TestCase;
+
+class TextExportTest extends TestCase
+{
+    public function test_page_text_export()
+    {
+        $page = $this->entities->page();
+        $this->asEditor();
+
+        $resp = $this->get($page->getUrl('/export/plaintext'));
+        $resp->assertStatus(200);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
+    }
+
+    public function test_book_text_export()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $directPage = $book->directPages()->first();
+        $chapter = $book->chapters()->first();
+        $chapterPage = $chapter->pages()->first();
+        $this->entities->updatePage($directPage, ['html' => '<p>My awesome page</p>']);
+        $this->entities->updatePage($chapterPage, ['html' => '<p>My little nested page</p>']);
+        $this->asEditor();
+
+        $resp = $this->get($book->getUrl('/export/plaintext'));
+        $resp->assertStatus(200);
+        $resp->assertSee($book->name);
+        $resp->assertSee($chapterPage->name);
+        $resp->assertSee($chapter->name);
+        $resp->assertSee($directPage->name);
+        $resp->assertSee('My awesome page');
+        $resp->assertSee('My little nested page');
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"');
+    }
+
+    public function test_book_text_export_format()
+    {
+        $entities = $this->entities->createChainBelongingToUser($this->users->viewer());
+        $this->entities->updatePage($entities['page'], ['html' => '<p>My great page</p><p>Full of <strong>great</strong> stuff</p>', 'name' => 'My wonderful page!']);
+        $entities['chapter']->name = 'Export chapter';
+        $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within";
+        $entities['book']->name = 'Export Book';
+        $entities['book']->description = "This is a book with stuff to export";
+        $entities['chapter']->save();
+        $entities['book']->save();
+
+        $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext'));
+
+        $expected = "Export Book\nThis is a book with stuff to export\n\nExport chapter\nA test chapter to be exported\nIt has loads of info within\n\n";
+        $expected .= "My wonderful page!\nMy great page Full of great stuff";
+        $resp->assertSee($expected);
+    }
+
+    public function test_chapter_text_export()
+    {
+        $chapter = $this->entities->chapter();
+        $page = $chapter->pages[0];
+        $this->entities->updatePage($page, ['html' => '<p>This is content within the page!</p>']);
+        $this->asEditor();
+
+        $resp = $this->get($chapter->getUrl('/export/plaintext'));
+        $resp->assertStatus(200);
+        $resp->assertSee($chapter->name);
+        $resp->assertSee($page->name);
+        $resp->assertSee('This is content within the page!');
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"');
+    }
+
+    public function test_chapter_text_export_format()
+    {
+        $entities = $this->entities->createChainBelongingToUser($this->users->viewer());
+        $this->entities->updatePage($entities['page'], ['html' => '<p>My great page</p><p>Full of <strong>great</strong> stuff</p>', 'name' => 'My wonderful page!']);
+        $entities['chapter']->name = 'Export chapter';
+        $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within";
+        $entities['chapter']->save();
+
+        $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext'));
+
+        $expected = "Export chapter\nA test chapter to be exported\nIt has loads of info within\n\n";
+        $expected .= "My wonderful page!\nMy great page Full of great stuff";
+        $resp->assertSee($expected);
+    }
+}
diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php
new file mode 100644 (file)
index 0000000..ebe07d0
--- /dev/null
@@ -0,0 +1,403 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Activity\Models\Tag;
+use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Uploads\Attachment;
+use BookStack\Uploads\Image;
+use Illuminate\Support\Carbon;
+use Illuminate\Testing\TestResponse;
+use Tests\TestCase;
+use ZipArchive;
+
+class ZipExportTest extends TestCase
+{
+    public function test_export_results_in_zip_format()
+    {
+        $page = $this->entities->page();
+        $response = $this->asEditor()->get($page->getUrl("/export/zip"));
+
+        $zipData = $response->streamedContent();
+        $zipFile = tempnam(sys_get_temp_dir(), 'bstesta-');
+        file_put_contents($zipFile, $zipData);
+        $zip = new ZipArchive();
+        $zip->open($zipFile, ZipArchive::RDONLY);
+
+        $this->assertNotFalse($zip->locateName('data.json'));
+        $this->assertNotFalse($zip->locateName('files/'));
+
+        $data = json_decode($zip->getFromName('data.json'), true);
+        $this->assertIsArray($data);
+        $this->assertGreaterThan(0, count($data));
+
+        $zip->close();
+        unlink($zipFile);
+    }
+
+    public function test_export_metadata()
+    {
+        $page = $this->entities->page();
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $this->assertEquals($page->id, $zip->data['page']['id'] ?? null);
+        $this->assertArrayNotHasKey('book', $zip->data);
+        $this->assertArrayNotHasKey('chapter', $zip->data);
+
+        $now = time();
+        $date = Carbon::parse($zip->data['exported_at'])->unix();
+        $this->assertLessThan($now + 2, $date);
+        $this->assertGreaterThan($now - 2, $date);
+
+        $version = trim(file_get_contents(base_path('version')));
+        $this->assertEquals($version, $zip->data['instance']['version']);
+
+        $zipInstanceId = $zip->data['instance']['id'];
+        $instanceId = setting('instance-id');
+        $this->assertNotEmpty($instanceId);
+        $this->assertEquals($instanceId, $zipInstanceId);
+    }
+
+    public function test_page_export()
+    {
+        $page = $this->entities->page();
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $pageData = $zip->data['page'];
+        $this->assertEquals([
+            'id' => $page->id,
+            'name' => $page->name,
+            'html' => (new PageContent($page))->render(),
+            'priority' => $page->priority,
+            'attachments' => [],
+            'images' => [],
+            'tags' => [],
+        ], $pageData);
+    }
+
+    public function test_page_export_with_markdown()
+    {
+        $page = $this->entities->page();
+        $markdown = "# My page\n\nwritten in markdown for export\n";
+        $page->markdown = $markdown;
+        $page->save();
+
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $pageData = $zip->data['page'];
+        $this->assertEquals($markdown, $pageData['markdown']);
+        $this->assertNotEmpty($pageData['html']);
+    }
+
+    public function test_page_export_with_tags()
+    {
+        $page = $this->entities->page();
+        $page->tags()->saveMany([
+            new Tag(['name' => 'Exporty', 'value' => 'Content', 'order' => 1]),
+            new Tag(['name' => 'Another', 'value' => '', 'order' => 2]),
+        ]);
+
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $pageData = $zip->data['page'];
+        $this->assertEquals([
+            [
+                'name' => 'Exporty',
+                'value' => 'Content',
+            ],
+            [
+                'name' => 'Another',
+                'value' => '',
+            ]
+        ], $pageData['tags']);
+    }
+
+    public function test_page_export_with_images()
+    {
+        $this->asEditor();
+        $page = $this->entities->page();
+        $result = $this->files->uploadGalleryImageToPage($this, $page);
+        $displayThumb = $result['response']->thumbs->gallery ?? '';
+        $page->html = '<p><img src="' . $displayThumb . '" alt="My image"></p>';
+        $page->save();
+        $image = Image::findOrFail($result['response']->id);
+
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $pageData = $zip->data['page'];
+
+        $this->assertCount(1, $pageData['images']);
+        $imageData = $pageData['images'][0];
+        $this->assertEquals($image->id, $imageData['id']);
+        $this->assertEquals($image->name, $imageData['name']);
+        $this->assertEquals('gallery', $imageData['type']);
+        $this->assertNotEmpty($imageData['file']);
+
+        $filePath = $zip->extractPath("files/{$imageData['file']}");
+        $this->assertFileExists($filePath);
+        $this->assertEquals(file_get_contents(public_path($image->path)), file_get_contents($filePath));
+
+        $this->assertEquals('<p><img src="[[bsexport:image:' . $imageData['id'] . ']]" alt="My image"></p>', $pageData['html']);
+    }
+
+    public function test_page_export_file_attachments()
+    {
+        $contents = 'My great attachment content!';
+
+        $page = $this->entities->page();
+        $this->asAdmin();
+        $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'PageAttachmentExport.txt', $contents, 'text/plain');
+
+        $zipResp = $this->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $pageData = $zip->data['page'];
+        $this->assertCount(1, $pageData['attachments']);
+
+        $attachmentData = $pageData['attachments'][0];
+        $this->assertEquals('PageAttachmentExport.txt', $attachmentData['name']);
+        $this->assertEquals($attachment->id, $attachmentData['id']);
+        $this->assertArrayNotHasKey('link', $attachmentData);
+        $this->assertNotEmpty($attachmentData['file']);
+
+        $fileRef = $attachmentData['file'];
+        $filePath = $zip->extractPath("/files/$fileRef");
+        $this->assertFileExists($filePath);
+        $this->assertEquals($contents, file_get_contents($filePath));
+    }
+
+    public function test_page_export_link_attachments()
+    {
+        $page = $this->entities->page();
+        $this->asEditor();
+        $attachment = Attachment::factory()->create([
+            'name' => 'My link attachment for export',
+            'path' => 'https://example.com/cats',
+            'external' => true,
+            'uploaded_to' => $page->id,
+            'order' => 1,
+        ]);
+
+        $zipResp = $this->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $pageData = $zip->data['page'];
+        $this->assertCount(1, $pageData['attachments']);
+
+        $attachmentData = $pageData['attachments'][0];
+        $this->assertEquals('My link attachment for export', $attachmentData['name']);
+        $this->assertEquals($attachment->id, $attachmentData['id']);
+        $this->assertEquals('https://example.com/cats', $attachmentData['link']);
+        $this->assertArrayNotHasKey('file', $attachmentData);
+    }
+
+    public function test_book_export()
+    {
+        $book = $this->entities->book();
+        $book->tags()->saveMany(Tag::factory()->count(2)->make());
+
+        $zipResp = $this->asEditor()->get($book->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $this->assertArrayHasKey('book', $zip->data);
+
+        $bookData = $zip->data['book'];
+        $this->assertEquals($book->id, $bookData['id']);
+        $this->assertEquals($book->name, $bookData['name']);
+        $this->assertEquals($book->descriptionHtml(), $bookData['description_html']);
+        $this->assertCount(2, $bookData['tags']);
+        $this->assertCount($book->directPages()->count(), $bookData['pages']);
+        $this->assertCount($book->chapters()->count(), $bookData['chapters']);
+        $this->assertArrayNotHasKey('cover', $bookData);
+    }
+
+    public function test_book_export_with_cover_image()
+    {
+        $book = $this->entities->book();
+        $bookRepo = $this->app->make(BookRepo::class);
+        $coverImageFile = $this->files->uploadedImage('cover.png');
+        $bookRepo->updateCoverImage($book, $coverImageFile);
+        $coverImage = $book->cover()->first();
+
+        $zipResp = $this->asEditor()->get($book->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $this->assertArrayHasKey('cover', $zip->data['book']);
+        $coverRef = $zip->data['book']['cover'];
+        $coverPath = $zip->extractPath("/files/$coverRef");
+        $this->assertFileExists($coverPath);
+        $this->assertEquals(file_get_contents(public_path($coverImage->path)), file_get_contents($coverPath));
+    }
+
+    public function test_chapter_export()
+    {
+        $chapter = $this->entities->chapter();
+        $chapter->tags()->saveMany(Tag::factory()->count(2)->make());
+
+        $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $this->assertArrayHasKey('chapter', $zip->data);
+
+        $chapterData = $zip->data['chapter'];
+        $this->assertEquals($chapter->id, $chapterData['id']);
+        $this->assertEquals($chapter->name, $chapterData['name']);
+        $this->assertEquals($chapter->descriptionHtml(), $chapterData['description_html']);
+        $this->assertCount(2, $chapterData['tags']);
+        $this->assertEquals($chapter->priority, $chapterData['priority']);
+        $this->assertCount($chapter->pages()->count(), $chapterData['pages']);
+    }
+
+
+    public function test_cross_reference_links_are_converted()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $chapter = $book->chapters()->first();
+        $page = $chapter->pages()->first();
+
+        $book->description_html = '<p><a href="' . $chapter->getUrl() . '">Link to chapter</a></p>';
+        $book->save();
+        $chapter->description_html = '<p><a href="' . $page->getUrl() . '#section2">Link to page</a></p>';
+        $chapter->save();
+        $page->html = '<p><a href="' . $book->getUrl() . '?view=true">Link to book</a></p>';
+        $page->save();
+
+        $zipResp = $this->asEditor()->get($book->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $bookData = $zip->data['book'];
+        $chapterData = $bookData['chapters'][0];
+        $pageData = $chapterData['pages'][0];
+
+        $this->assertStringContainsString('href="[[bsexport:chapter:' . $chapter->id . ']]"', $bookData['description_html']);
+        $this->assertStringContainsString('href="[[bsexport:page:' . $page->id . ']]#section2"', $chapterData['description_html']);
+        $this->assertStringContainsString('href="[[bsexport:book:' . $book->id . ']]?view=true"', $pageData['html']);
+    }
+
+    public function test_book_and_chapter_description_links_to_images_in_pages_are_converted()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $chapter = $book->chapters()->first();
+        $page = $chapter->pages()->first();
+
+        $this->asEditor();
+        $this->files->uploadGalleryImageToPage($this, $page);
+        /** @var Image $image */
+        $image = Image::query()->where('type', '=', 'gallery')
+            ->where('uploaded_to', '=', $page->id)->first();
+
+        $book->description_html = '<p><a href="' . $image->url . '">Link to image</a></p>';
+        $book->save();
+        $chapter->description_html = '<p><a href="' . $image->url . '">Link to image</a></p>';
+        $chapter->save();
+
+        $zipResp = $this->get($book->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $bookData = $zip->data['book'];
+        $chapterData = $bookData['chapters'][0];
+
+        $this->assertStringContainsString('href="[[bsexport:image:' . $image->id . ']]"', $bookData['description_html']);
+        $this->assertStringContainsString('href="[[bsexport:image:' . $image->id . ']]"', $chapterData['description_html']);
+    }
+
+    public function test_image_links_are_handled_when_using_external_storage_url()
+    {
+        $page = $this->entities->page();
+
+        $this->asEditor();
+        $this->files->uploadGalleryImageToPage($this, $page);
+        /** @var Image $image */
+        $image = Image::query()->where('type', '=', 'gallery')
+            ->where('uploaded_to', '=', $page->id)->first();
+
+        config()->set('filesystems.url', 'https://i.example.com/content');
+
+        $storageUrl = 'https://i.example.com/content/' . ltrim($image->path, '/');
+        $page->html = '<p><a href="' . $image->url . '">Original URL</a><a href="' . $storageUrl . '">Storage URL</a></p>';
+        $page->save();
+
+        $zipResp = $this->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $pageData = $zip->data['page'];
+
+        $ref = '[[bsexport:image:' . $image->id . ']]';
+        $this->assertStringContainsString("<a href=\"{$ref}\">Original URL</a><a href=\"{$ref}\">Storage URL</a>", $pageData['html']);
+    }
+
+    public function test_cross_reference_links_external_to_export_are_not_converted()
+    {
+        $page = $this->entities->page();
+        $page->html = '<p><a href="' . $page->book->getUrl() . '">Link to book</a></p>';
+        $page->save();
+
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $pageData = $zip->data['page'];
+
+        $this->assertStringContainsString('href="' . $page->book->getUrl() . '"', $pageData['html']);
+    }
+
+    public function test_attachments_links_are_converted()
+    {
+        $page = $this->entities->page();
+        $attachment = Attachment::factory()->create([
+            'name' => 'My link attachment for export reference',
+            'path' => 'https://example.com/cats/ref',
+            'external' => true,
+            'uploaded_to' => $page->id,
+            'order' => 1,
+        ]);
+
+        $page->html = '<p><a href="' . url("/attachments/{$attachment->id}") . '?open=true">Link to attachment</a></p>';
+        $page->save();
+
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $pageData = $zip->data['page'];
+
+        $this->assertStringContainsString('href="[[bsexport:attachment:' . $attachment->id . ']]?open=true"', $pageData['html']);
+    }
+
+    public function test_links_in_markdown_are_parsed()
+    {
+        $chapter = $this->entities->chapterHasPages();
+        $page = $chapter->pages()->first();
+
+        $page->markdown = "[Link to chapter]({$chapter->getUrl()})";
+        $page->save();
+
+        $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+        $pageData = $zip->data['chapter']['pages'][0];
+
+        $this->assertStringContainsString("[Link to chapter]([[bsexport:chapter:{$chapter->id}]])", $pageData['markdown']);
+    }
+
+    protected function extractZipResponse(TestResponse $response): ZipResultData
+    {
+        $zipData = $response->streamedContent();
+        $zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
+
+        file_put_contents($zipFile, $zipData);
+        $extractDir = tempnam(sys_get_temp_dir(), 'bstestextracted-');
+        if (file_exists($extractDir)) {
+            unlink($extractDir);
+        }
+        mkdir($extractDir);
+
+        $zip = new ZipArchive();
+        $zip->open($zipFile, ZipArchive::RDONLY);
+        $zip->extractTo($extractDir);
+
+        $dataJson = file_get_contents($extractDir . DIRECTORY_SEPARATOR . "data.json");
+        $data = json_decode($dataJson, true);
+
+        return new ZipResultData(
+            $zipFile,
+            $extractDir,
+            $data,
+        );
+    }
+}
diff --git a/tests/Exports/ZipExportValidatorTest.php b/tests/Exports/ZipExportValidatorTest.php
new file mode 100644 (file)
index 0000000..c453ef2
--- /dev/null
@@ -0,0 +1,93 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Exports\ZipExports\ZipExportReader;
+use BookStack\Exports\ZipExports\ZipExportValidator;
+use BookStack\Exports\ZipExports\ZipImportRunner;
+use BookStack\Uploads\Image;
+use Tests\TestCase;
+
+class ZipExportValidatorTest extends TestCase
+{
+    protected array $filesToRemove = [];
+
+    protected function tearDown(): void
+    {
+        foreach ($this->filesToRemove as $file) {
+            unlink($file);
+        }
+
+        parent::tearDown();
+    }
+
+    protected function getValidatorForData(array $zipData, array $files = []): ZipExportValidator
+    {
+        $upload = ZipTestHelper::zipUploadFromData($zipData, $files);
+        $path = $upload->getRealPath();
+        $this->filesToRemove[] = $path;
+        $reader = new ZipExportReader($path);
+        return new ZipExportValidator($reader);
+    }
+
+    public function test_ids_have_to_be_unique()
+    {
+        $validator = $this->getValidatorForData([
+            'book' => [
+                'id' => 4,
+                'name' => 'My book',
+                'pages' => [
+                    [
+                        'id' => 4,
+                        'name' => 'My page',
+                        'markdown' => 'hello',
+                        'attachments' => [
+                            ['id' => 4, 'name' => 'Attachment A', 'link' => 'https://example.com'],
+                            ['id' => 4, 'name' => 'Attachment B', 'link' => 'https://example.com']
+                        ],
+                        'images' => [
+                            ['id' => 4, 'name' => 'Image A', 'type' => 'gallery', 'file' => 'cat'],
+                            ['id' => 4, 'name' => 'Image b', 'type' => 'gallery', 'file' => 'cat'],
+                        ],
+                    ],
+                    ['id' => 4, 'name' => 'My page', 'markdown' => 'hello'],
+                ],
+                'chapters' => [
+                    ['id' => 4, 'name' => 'Chapter 1'],
+                    ['id' => 4, 'name' => 'Chapter 2']
+                ]
+            ]
+        ], ['cat' => $this->files->testFilePath('test-image.png')]);
+
+        $results = $validator->validate();
+        $this->assertCount(4, $results);
+
+        $expectedMessage = 'The id must be unique for the object type within the ZIP.';
+        $this->assertEquals($expectedMessage, $results['book.pages.0.attachments.1.id']);
+        $this->assertEquals($expectedMessage, $results['book.pages.0.images.1.id']);
+        $this->assertEquals($expectedMessage, $results['book.pages.1.id']);
+        $this->assertEquals($expectedMessage, $results['book.chapters.1.id']);
+    }
+
+    public function test_image_files_need_to_be_a_valid_detected_image_file()
+    {
+        $validator = $this->getValidatorForData([
+            'page' => [
+                'id' => 4,
+                'name' => 'My page',
+                'markdown' => 'hello',
+                'images' => [
+                    ['id' => 4, 'name' => 'Image A', 'type' => 'gallery', 'file' => 'cat'],
+                ],
+            ]
+        ], ['cat' => $this->files->testFilePath('test-file.txt')]);
+
+        $results = $validator->validate();
+        $this->assertCount(1, $results);
+
+        $this->assertEquals('The file needs to reference a file of type image/png,image/jpeg,image/gif,image/webp, found text/plain.', $results['page.images.0.file']);
+    }
+}
diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php
new file mode 100644 (file)
index 0000000..d3af6df
--- /dev/null
@@ -0,0 +1,396 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Exports\ZipExports\ZipImportRunner;
+use BookStack\Uploads\Image;
+use Tests\TestCase;
+
+class ZipImportRunnerTest extends TestCase
+{
+    protected ZipImportRunner $runner;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->runner = app()->make(ZipImportRunner::class);
+    }
+
+    public function test_book_import()
+    {
+        $testImagePath = $this->files->testFilePath('test-image.png');
+        $testFilePath = $this->files->testFilePath('test-file.txt');
+        $import = ZipTestHelper::importFromData([], [
+            'book' => [
+                'id' => 5,
+                'name' => 'Import test',
+                'cover' => 'book_cover_image',
+                'description_html' => '<p><a href="[[bsexport:page:3]]">Link to chapter page</a></p>',
+                'tags' => [
+                    ['name' => 'Animal', 'value' => 'Cat'],
+                    ['name' => 'Category', 'value' => 'Test'],
+                ],
+                'chapters' => [
+                    [
+                        'id' => 6,
+                        'name' => 'Chapter A',
+                        'description_html' => '<p><a href="[[bsexport:book:5]]">Link to book</a></p>',
+                        'priority' => 1,
+                        'tags' => [
+                            ['name' => 'Reviewed'],
+                            ['name' => 'Category', 'value' => 'Test Chapter'],
+                        ],
+                        'pages' => [
+                            [
+                                'id' => 3,
+                                'name' => 'Page A',
+                                'priority' => 6,
+                                'html' => '
+<p><a href="[[bsexport:page:3]]">Link to self</a></p>
+<p><a href="[[bsexport:image:1]]">Link to cat image</a></p>
+<p><a href="[[bsexport:attachment:4]]">Link to text attachment</a></p>',
+                                'tags' => [
+                                    ['name' => 'Unreviewed'],
+                                ],
+                                'attachments' => [
+                                    [
+                                        'id' => 4,
+                                        'name' => 'Text attachment',
+                                        'file' => 'file_attachment'
+                                    ],
+                                    [
+                                        'name' => 'Cats',
+                                        'link' => 'https://example.com/cats',
+                                    ]
+                                ],
+                                'images' => [
+                                    [
+                                        'id' => 1,
+                                        'name' => 'Cat',
+                                        'type' => 'gallery',
+                                        'file' => 'cat_image'
+                                    ],
+                                    [
+                                        'id' => 2,
+                                        'name' => 'Dog Drawing',
+                                        'type' => 'drawio',
+                                        'file' => 'dog_image'
+                                    ]
+                                ],
+                            ],
+                        ],
+                    ],
+                    [
+                        'name' => 'Chapter child B',
+                        'priority' => 5,
+                    ]
+                ],
+                'pages' => [
+                    [
+                        'name' => 'Page C',
+                        'markdown' => '[Link to text]([[bsexport:attachment:4]]?scale=big)',
+                        'priority' => 3,
+                    ]
+                ],
+            ],
+        ], [
+            'book_cover_image' => $testImagePath,
+            'file_attachment'  => $testFilePath,
+            'cat_image' => $testImagePath,
+            'dog_image' => $testImagePath,
+        ]);
+
+        $this->asAdmin();
+        /** @var Book $book */
+        $book = $this->runner->run($import);
+
+        // Book checks
+        $this->assertEquals('Import test', $book->name);
+        $this->assertFileExists(public_path($book->cover->path));
+        $this->assertCount(2, $book->tags);
+        $this->assertEquals('Cat', $book->tags()->first()->value);
+        $this->assertCount(2, $book->chapters);
+        $this->assertEquals(1, $book->directPages()->count());
+
+        // Chapter checks
+        $chapterA = $book->chapters()->where('name', 'Chapter A')->first();
+        $this->assertCount(2, $chapterA->tags);
+        $firstChapterTag = $chapterA->tags()->first();
+        $this->assertEquals('Reviewed', $firstChapterTag->name);
+        $this->assertEquals('', $firstChapterTag->value);
+        $this->assertCount(1, $chapterA->pages);
+
+        // Page checks
+        /** @var Page $pageA */
+        $pageA = $chapterA->pages->first();
+        $this->assertEquals('Page A', $pageA->name);
+        $this->assertCount(1, $pageA->tags);
+        $firstPageTag = $pageA->tags()->first();
+        $this->assertEquals('Unreviewed', $firstPageTag->name);
+        $this->assertCount(2, $pageA->attachments);
+        $firstAttachment = $pageA->attachments->first();
+        $this->assertEquals('Text attachment', $firstAttachment->name);
+        $this->assertFileEquals($testFilePath, storage_path($firstAttachment->path));
+        $this->assertFalse($firstAttachment->external);
+        $secondAttachment = $pageA->attachments->last();
+        $this->assertEquals('Cats', $secondAttachment->name);
+        $this->assertEquals('https://example.com/cats', $secondAttachment->path);
+        $this->assertTrue($secondAttachment->external);
+        $pageAImages = Image::where('uploaded_to', '=', $pageA->id)->whereIn('type', ['gallery', 'drawio'])->get();
+        $this->assertCount(2, $pageAImages);
+        $this->assertEquals('Cat', $pageAImages[0]->name);
+        $this->assertEquals('gallery', $pageAImages[0]->type);
+        $this->assertFileEquals($testImagePath, public_path($pageAImages[0]->path));
+        $this->assertEquals('Dog Drawing', $pageAImages[1]->name);
+        $this->assertEquals('drawio', $pageAImages[1]->type);
+
+        // Book order check
+        $children = $book->getDirectVisibleChildren()->values()->all();
+        $this->assertEquals($children[0]->name, 'Chapter A');
+        $this->assertEquals($children[1]->name, 'Page C');
+        $this->assertEquals($children[2]->name, 'Chapter child B');
+
+        // Reference checks
+        $textAttachmentUrl = $firstAttachment->getUrl();
+        $this->assertStringContainsString($pageA->getUrl(), $book->description_html);
+        $this->assertStringContainsString($book->getUrl(), $chapterA->description_html);
+        $this->assertStringContainsString($pageA->getUrl(), $pageA->html);
+        $this->assertStringContainsString($pageAImages[0]->getThumb(1680, null, true), $pageA->html);
+        $this->assertStringContainsString($firstAttachment->getUrl(), $pageA->html);
+
+        // Reference in converted markdown
+        $pageC = $children[1];
+        $this->assertStringContainsString("href=\"{$textAttachmentUrl}?scale=big\"", $pageC->html);
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_chapter_import()
+    {
+        $testImagePath = $this->files->testFilePath('test-image.png');
+        $testFilePath = $this->files->testFilePath('test-file.txt');
+        $parent = $this->entities->book();
+
+        $import = ZipTestHelper::importFromData([], [
+            'chapter' => [
+                'id' => 6,
+                'name' => 'Chapter A',
+                'description_html' => '<p><a href="[[bsexport:page:3]]">Link to page</a></p>',
+                'priority' => 1,
+                'tags' => [
+                    ['name' => 'Reviewed', 'value' => '2024'],
+                ],
+                'pages' => [
+                    [
+                        'id' => 3,
+                        'name' => 'Page A',
+                        'priority' => 6,
+                        'html' => '<p><a href="[[bsexport:chapter:6]]">Link to chapter</a></p>
+<p><a href="[[bsexport:image:2]]">Link to dog drawing</a></p>
+<p><a href="[[bsexport:attachment:4]]">Link to text attachment</a></p>',
+                        'tags' => [
+                            ['name' => 'Unreviewed'],
+                        ],
+                        'attachments' => [
+                            [
+                                'id' => 4,
+                                'name' => 'Text attachment',
+                                'file' => 'file_attachment'
+                            ]
+                        ],
+                        'images' => [
+                            [
+                                'id' => 2,
+                                'name' => 'Dog Drawing',
+                                'type' => 'drawio',
+                                'file' => 'dog_image'
+                            ]
+                        ],
+                    ],
+                    [
+                        'name' => 'Page B',
+                        'markdown' => '[Link to page A]([[bsexport:page:3]])',
+                        'priority' => 9,
+                    ],
+                ],
+            ],
+        ], [
+            'file_attachment'  => $testFilePath,
+            'dog_image' => $testImagePath,
+        ]);
+
+        $this->asAdmin();
+        /** @var Chapter $chapter */
+        $chapter = $this->runner->run($import, $parent);
+
+        // Chapter checks
+        $this->assertEquals('Chapter A', $chapter->name);
+        $this->assertEquals($parent->id, $chapter->book_id);
+        $this->assertCount(1, $chapter->tags);
+        $firstChapterTag = $chapter->tags()->first();
+        $this->assertEquals('Reviewed', $firstChapterTag->name);
+        $this->assertEquals('2024', $firstChapterTag->value);
+        $this->assertCount(2, $chapter->pages);
+
+        // Page checks
+        /** @var Page $pageA */
+        $pageA = $chapter->pages->first();
+        $this->assertEquals('Page A', $pageA->name);
+        $this->assertCount(1, $pageA->tags);
+        $this->assertCount(1, $pageA->attachments);
+        $pageAImages = Image::where('uploaded_to', '=', $pageA->id)->whereIn('type', ['gallery', 'drawio'])->get();
+        $this->assertCount(1, $pageAImages);
+
+        // Reference checks
+        $attachment = $pageA->attachments->first();
+        $this->assertStringContainsString($pageA->getUrl(), $chapter->description_html);
+        $this->assertStringContainsString($chapter->getUrl(), $pageA->html);
+        $this->assertStringContainsString($pageAImages[0]->url, $pageA->html);
+        $this->assertStringContainsString($attachment->getUrl(), $pageA->html);
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_page_import()
+    {
+        $testImagePath = $this->files->testFilePath('test-image.png');
+        $testFilePath = $this->files->testFilePath('test-file.txt');
+        $parent = $this->entities->chapter();
+
+        $import = ZipTestHelper::importFromData([], [
+            'page' => [
+                'id' => 3,
+                'name' => 'Page A',
+                'priority' => 6,
+                'html' => '<p><a href="[[bsexport:page:3]]">Link to self</a></p>
+<p><a href="[[bsexport:image:2]]">Link to dog drawing</a></p>
+<p><a href="[[bsexport:attachment:4]]">Link to text attachment</a></p>',
+                'tags' => [
+                    ['name' => 'Unreviewed'],
+                ],
+                'attachments' => [
+                    [
+                        'id' => 4,
+                        'name' => 'Text attachment',
+                        'file' => 'file_attachment'
+                    ]
+                ],
+                'images' => [
+                    [
+                        'id' => 2,
+                        'name' => 'Dog Drawing',
+                        'type' => 'drawio',
+                        'file' => 'dog_image'
+                    ]
+                ],
+            ],
+        ], [
+            'file_attachment'  => $testFilePath,
+            'dog_image' => $testImagePath,
+        ]);
+
+        $this->asAdmin();
+        /** @var Page $page */
+        $page = $this->runner->run($import, $parent);
+
+        // Page checks
+        $this->assertEquals('Page A', $page->name);
+        $this->assertCount(1, $page->tags);
+        $this->assertCount(1, $page->attachments);
+        $pageImages = Image::where('uploaded_to', '=', $page->id)->whereIn('type', ['gallery', 'drawio'])->get();
+        $this->assertCount(1, $pageImages);
+        $this->assertFileEquals($testImagePath, public_path($pageImages[0]->path));
+
+        // Reference checks
+        $this->assertStringContainsString($page->getUrl(), $page->html);
+        $this->assertStringContainsString($pageImages[0]->url, $page->html);
+        $this->assertStringContainsString($page->attachments->first()->getUrl(), $page->html);
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_revert_cleans_up_uploaded_files()
+    {
+        $testImagePath = $this->files->testFilePath('test-image.png');
+        $testFilePath = $this->files->testFilePath('test-file.txt');
+        $parent = $this->entities->chapter();
+
+        $import = ZipTestHelper::importFromData([], [
+            'page' => [
+                'name' => 'Page A',
+                'html' => '<p>Hello</p>',
+                'attachments' => [
+                    [
+                        'name' => 'Text attachment',
+                        'file' => 'file_attachment'
+                    ]
+                ],
+                'images' => [
+                    [
+                        'name' => 'Dog Image',
+                        'type' => 'gallery',
+                        'file' => 'dog_image'
+                    ]
+                ],
+            ],
+        ], [
+            'file_attachment'  => $testFilePath,
+            'dog_image' => $testImagePath,
+        ]);
+
+        $this->asAdmin();
+        /** @var Page $page */
+        $page = $this->runner->run($import, $parent);
+
+        $attachment = $page->attachments->first();
+        $image = Image::query()->where('uploaded_to', '=', $page->id)->where('type', '=', 'gallery')->first();
+
+        $this->assertFileExists(public_path($image->path));
+        $this->assertFileExists(storage_path($attachment->path));
+
+        $this->runner->revertStoredFiles();
+
+        $this->assertFileDoesNotExist(public_path($image->path));
+        $this->assertFileDoesNotExist(storage_path($attachment->path));
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_imported_images_have_their_detected_extension_added()
+    {
+        $testImagePath = $this->files->testFilePath('test-image.png');
+        $parent = $this->entities->chapter();
+
+        $import = ZipTestHelper::importFromData([], [
+            'page' => [
+                'name' => 'Page A',
+                'html' => '<p>hello</p>',
+                'images' => [
+                    [
+                        'id' => 2,
+                        'name' => 'Cat',
+                        'type' => 'gallery',
+                        'file' => 'cat_image'
+                    ]
+                ],
+            ],
+        ], [
+            'cat_image' => $testImagePath,
+        ]);
+
+        $this->asAdmin();
+        /** @var Page $page */
+        $page = $this->runner->run($import, $parent);
+
+        $pageImages = Image::where('uploaded_to', '=', $page->id)->whereIn('type', ['gallery', 'drawio'])->get();
+
+        $this->assertCount(1, $pageImages);
+        $this->assertStringEndsWith('.png', $pageImages[0]->url);
+        $this->assertStringEndsWith('.png', $pageImages[0]->path);
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+}
diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php
new file mode 100644 (file)
index 0000000..ad0e6b2
--- /dev/null
@@ -0,0 +1,396 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Activity\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Exports\Import;
+use BookStack\Exports\ZipExports\Models\ZipExportBook;
+use BookStack\Exports\ZipExports\Models\ZipExportChapter;
+use BookStack\Exports\ZipExports\Models\ZipExportPage;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Testing\TestResponse;
+use Tests\TestCase;
+use ZipArchive;
+
+class ZipImportTest extends TestCase
+{
+    public function test_import_page_view()
+    {
+        $resp = $this->asAdmin()->get('/import');
+        $resp->assertSee('Import');
+        $this->withHtml($resp)->assertElementExists('form input[type="file"][name="file"]');
+    }
+
+    public function test_permissions_needed_for_import_page()
+    {
+        $user = $this->users->viewer();
+        $this->actingAs($user);
+
+        $resp = $this->get('/books');
+        $this->withHtml($resp)->assertLinkNotExists(url('/import'));
+        $resp = $this->get('/import');
+        $resp->assertRedirect('/');
+
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $resp = $this->get('/books');
+        $this->withHtml($resp)->assertLinkExists(url('/import'));
+        $resp = $this->get('/import');
+        $resp->assertOk();
+        $resp->assertSeeText('Select ZIP file to upload');
+    }
+
+    public function test_import_page_pending_import_visibility_limited()
+    {
+        $user = $this->users->viewer();
+        $admin = $this->users->admin();
+        $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
+        $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $resp = $this->actingAs($user)->get('/import');
+        $resp->assertSeeText('MySuperUserImport');
+        $resp->assertDontSeeText('MySuperAdminImport');
+
+        $this->permissions->grantUserRolePermissions($user, ['settings-manage']);
+
+        $resp = $this->actingAs($user)->get('/import');
+        $resp->assertSeeText('MySuperUserImport');
+        $resp->assertSeeText('MySuperAdminImport');
+    }
+
+    public function test_zip_read_errors_are_shown_on_validation()
+    {
+        $invalidUpload = $this->files->uploadedImage('image.zip');
+
+        $this->asAdmin();
+        $resp = $this->runImportFromFile($invalidUpload);
+        $resp->assertRedirect('/import');
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('Could not read ZIP file');
+    }
+
+    public function test_error_shown_if_missing_data()
+    {
+        $zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
+        $zip = new ZipArchive();
+        $zip->open($zipFile, ZipArchive::CREATE);
+        $zip->addFromString('beans', 'cat');
+        $zip->close();
+
+        $this->asAdmin();
+        $upload = new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true);
+        $resp = $this->runImportFromFile($upload);
+        $resp->assertRedirect('/import');
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('Could not find and decode ZIP data.json content.');
+    }
+
+    public function test_error_shown_if_no_importable_key()
+    {
+        $this->asAdmin();
+        $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData([
+            'instance' => []
+        ]));
+
+        $resp->assertRedirect('/import');
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('ZIP file data has no expected book, chapter or page content.');
+    }
+
+    public function test_zip_data_validation_messages_shown()
+    {
+        $this->asAdmin();
+        $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData([
+            'book' => [
+                'id' => 4,
+                'pages' => [
+                    'cat',
+                    [
+                        'name' => 'My inner page',
+                        'tags' => [
+                            [
+                                'value' => 5
+                            ]
+                        ],
+                    ]
+                ],
+            ]
+        ]));
+
+        $resp->assertRedirect('/import');
+        $resp = $this->followRedirects($resp);
+
+        $resp->assertSeeText('[book.name]: The name field is required.');
+        $resp->assertSeeText('[book.pages.0.0]: Data object expected but "string" found.');
+        $resp->assertSeeText('[book.pages.1.tags.0.name]: The name field is required.');
+        $resp->assertSeeText('[book.pages.1.tags.0.value]: The value must be a string.');
+    }
+
+    public function test_import_upload_success()
+    {
+        $admin = $this->users->admin();
+        $this->actingAs($admin);
+        $data = [
+            'book' => [
+                'name' => 'My great book name',
+                'chapters' => [
+                    [
+                        'name' => 'my chapter',
+                        'pages' => [
+                            [
+                                'name' => 'my chapter page',
+                            ]
+                        ]
+                    ]
+                ],
+                'pages' => [
+                    [
+                        'name' => 'My page',
+                    ]
+                ],
+            ],
+        ];
+
+        $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData($data));
+
+        $this->assertDatabaseHas('imports', [
+            'name' => 'My great book name',
+            'type' => 'book',
+            'created_by' => $admin->id,
+        ]);
+
+        /** @var Import $import */
+        $import = Import::query()->latest()->first();
+        $resp->assertRedirect("/import/{$import->id}");
+        $this->assertFileExists(storage_path($import->path));
+        $this->assertActivityExists(ActivityType::IMPORT_CREATE);
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_import_show_page()
+    {
+        $exportBook = new ZipExportBook();
+        $exportBook->name = 'My exported book';
+        $exportChapter = new ZipExportChapter();
+        $exportChapter->name = 'My exported chapter';
+        $exportPage = new ZipExportPage();
+        $exportPage->name = 'My exported page';
+        $exportBook->chapters = [$exportChapter];
+        $exportChapter->pages = [$exportPage];
+
+        $import = Import::factory()->create([
+            'name' => 'MySuperAdminImport',
+            'metadata' => json_encode($exportBook)
+        ]);
+
+        $resp = $this->asAdmin()->get("/import/{$import->id}");
+        $resp->assertOk();
+        $resp->assertSeeText('My exported book');
+        $resp->assertSeeText('My exported chapter');
+        $resp->assertSeeText('My exported page');
+    }
+
+    public function test_import_show_page_access_limited()
+    {
+        $user = $this->users->viewer();
+        $admin = $this->users->admin();
+        $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
+        $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
+        $this->actingAs($user);
+
+        $this->get("/import/{$userImport->id}")->assertRedirect('/');
+        $this->get("/import/{$adminImport->id}")->assertRedirect('/');
+
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $this->get("/import/{$userImport->id}")->assertOk();
+        $this->get("/import/{$adminImport->id}")->assertStatus(404);
+
+        $this->permissions->grantUserRolePermissions($user, ['settings-manage']);
+
+        $this->get("/import/{$userImport->id}")->assertOk();
+        $this->get("/import/{$adminImport->id}")->assertOk();
+    }
+
+    public function test_import_delete()
+    {
+        $this->asAdmin();
+        $this->runImportFromFile(ZipTestHelper::zipUploadFromData([
+            'book' => [
+                'name' => 'My great book name'
+            ],
+        ]));
+
+        /** @var Import $import */
+        $import = Import::query()->latest()->first();
+        $this->assertDatabaseHas('imports', [
+            'id' => $import->id,
+            'name' => 'My great book name'
+        ]);
+        $this->assertFileExists(storage_path($import->path));
+
+        $resp = $this->delete("/import/{$import->id}");
+
+        $resp->assertRedirect('/import');
+        $this->assertActivityExists(ActivityType::IMPORT_DELETE);
+        $this->assertDatabaseMissing('imports', [
+            'id' => $import->id,
+        ]);
+        $this->assertFileDoesNotExist(storage_path($import->path));
+    }
+
+    public function test_import_delete_access_limited()
+    {
+        $user = $this->users->viewer();
+        $admin = $this->users->admin();
+        $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
+        $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
+        $this->actingAs($user);
+
+        $this->delete("/import/{$userImport->id}")->assertRedirect('/');
+        $this->delete("/import/{$adminImport->id}")->assertRedirect('/');
+
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $this->delete("/import/{$userImport->id}")->assertRedirect('/import');
+        $this->delete("/import/{$adminImport->id}")->assertStatus(404);
+
+        $this->permissions->grantUserRolePermissions($user, ['settings-manage']);
+
+        $this->delete("/import/{$adminImport->id}")->assertRedirect('/import');
+    }
+
+    public function test_run_simple_success_scenario()
+    {
+        $import = ZipTestHelper::importFromData([], [
+            'book' => [
+                'name' => 'My imported book',
+                'pages' => [
+                    [
+                        'name' => 'My imported book page',
+                        'html' => '<p>Hello there from child page!</p>'
+                    ]
+                ],
+            ]
+        ]);
+
+        $resp = $this->asAdmin()->post("/import/{$import->id}");
+        $book = Book::query()->where('name', '=', 'My imported book')->latest()->first();
+        $resp->assertRedirect($book->getUrl());
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('My imported book page');
+        $resp->assertSee('Hello there from child page!');
+
+        $this->assertDatabaseMissing('imports', ['id' => $import->id]);
+        $this->assertFileDoesNotExist(storage_path($import->path));
+        $this->assertActivityExists(ActivityType::IMPORT_RUN, null, $import->logDescriptor());
+    }
+
+    public function test_import_run_access_limited()
+    {
+        $user = $this->users->editor();
+        $admin = $this->users->admin();
+        $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
+        $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
+        $this->actingAs($user);
+
+        $this->post("/import/{$userImport->id}")->assertRedirect('/');
+        $this->post("/import/{$adminImport->id}")->assertRedirect('/');
+
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $this->post("/import/{$userImport->id}")->assertRedirect($userImport->getUrl()); // Getting validation response instead of access issue response
+        $this->post("/import/{$adminImport->id}")->assertStatus(404);
+
+        $this->permissions->grantUserRolePermissions($user, ['settings-manage']);
+
+        $this->post("/import/{$adminImport->id}")->assertRedirect($adminImport->getUrl()); // Getting validation response instead of access issue response
+    }
+
+    public function test_run_revalidates_content()
+    {
+        $import = ZipTestHelper::importFromData([], [
+            'book' => [
+                'id' => 'abc',
+            ]
+        ]);
+
+        $resp = $this->asAdmin()->post("/import/{$import->id}");
+        $resp->assertRedirect($import->getUrl());
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('The name field is required.');
+        $resp->assertSeeText('The id must be an integer.');
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_run_checks_permissions_on_import()
+    {
+        $viewer = $this->users->viewer();
+        $this->permissions->grantUserRolePermissions($viewer, ['content-import']);
+        $import = ZipTestHelper::importFromData(['created_by' => $viewer->id], [
+            'book' => ['name' => 'My import book'],
+        ]);
+
+        $resp = $this->asViewer()->post("/import/{$import->id}");
+        $resp->assertRedirect($import->getUrl());
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('You are lacking the required permissions to create books.');
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    public function test_run_requires_parent_for_chapter_and_page_imports()
+    {
+        $book = $this->entities->book();
+        $pageImport = ZipTestHelper::importFromData([], [
+            'page' => ['name' => 'My page', 'html' => '<p>page test!</p>'],
+        ]);
+        $chapterImport = ZipTestHelper::importFromData([], [
+            'chapter' => ['name' => 'My chapter'],
+        ]);
+
+        $resp = $this->asAdmin()->post("/import/{$pageImport->id}");
+        $resp->assertRedirect($pageImport->getUrl());
+        $this->followRedirects($resp)->assertSee('The parent field is required.');
+
+        $resp = $this->asAdmin()->post("/import/{$pageImport->id}", ['parent' => "book:{$book->id}"]);
+        $resp->assertRedirectContains($book->getUrl());
+
+        $resp = $this->asAdmin()->post("/import/{$chapterImport->id}");
+        $resp->assertRedirect($chapterImport->getUrl());
+        $this->followRedirects($resp)->assertSee('The parent field is required.');
+
+        $resp = $this->asAdmin()->post("/import/{$chapterImport->id}", ['parent' => "book:{$book->id}"]);
+        $resp->assertRedirectContains($book->getUrl());
+    }
+
+    public function test_run_validates_correct_parent_type()
+    {
+        $chapter = $this->entities->chapter();
+        $import = ZipTestHelper::importFromData([], [
+            'chapter' => ['name' => 'My chapter'],
+        ]);
+
+        $resp = $this->asAdmin()->post("/import/{$import->id}", ['parent' => "chapter:{$chapter->id}"]);
+        $resp->assertRedirect($import->getUrl());
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('Parent book required for chapter import.');
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
+
+    protected function runImportFromFile(UploadedFile $file): TestResponse
+    {
+        return $this->call('POST', '/import', [], [], ['file' => $file]);
+    }
+}
diff --git a/tests/Exports/ZipResultData.php b/tests/Exports/ZipResultData.php
new file mode 100644 (file)
index 0000000..7725004
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+namespace Tests\Exports;
+
+class ZipResultData
+{
+    public function __construct(
+        public string $zipPath,
+        public string $extractedDirPath,
+        public array $data,
+    ) {
+    }
+
+    /**
+     * Build a path to a location the extracted content, using the given relative $path.
+     */
+    public function extractPath(string $path): string
+    {
+        $relPath = implode(DIRECTORY_SEPARATOR, explode('/', $path));
+        return $this->extractedDirPath . DIRECTORY_SEPARATOR . ltrim($relPath, DIRECTORY_SEPARATOR);
+    }
+}
diff --git a/tests/Exports/ZipTestHelper.php b/tests/Exports/ZipTestHelper.php
new file mode 100644 (file)
index 0000000..d830d8e
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+
+namespace Tests\Exports;
+
+use BookStack\Exports\Import;
+use Illuminate\Http\UploadedFile;
+use ZipArchive;
+
+class ZipTestHelper
+{
+    public static function importFromData(array $importData, array $zipData, array $files = []): Import
+    {
+        if (isset($zipData['book'])) {
+            $importData['type'] = 'book';
+        } else if (isset($zipData['chapter'])) {
+            $importData['type'] = 'chapter';
+        } else if (isset($zipData['page'])) {
+            $importData['type'] = 'page';
+        }
+
+        $import = Import::factory()->create($importData);
+        $zip = static::zipUploadFromData($zipData, $files);
+        $targetPath = storage_path($import->path);
+        $targetDir = dirname($targetPath);
+
+        if (!file_exists($targetDir)) {
+            mkdir($targetDir);
+        }
+
+        rename($zip->getRealPath(), $targetPath);
+
+        return $import;
+    }
+
+    public static function deleteZipForImport(Import $import): void
+    {
+        $path = storage_path($import->path);
+        if (file_exists($path)) {
+            unlink($path);
+        }
+    }
+
+    public static function zipUploadFromData(array $data, array $files = []): UploadedFile
+    {
+        $zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
+
+        $zip = new ZipArchive();
+        $zip->open($zipFile, ZipArchive::CREATE);
+        $zip->addFromString('data.json', json_encode($data));
+
+        foreach ($files as $name => $file) {
+            $zip->addFile($file, "files/$name");
+        }
+
+        $zip->close();
+
+        return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true);
+    }
+}
index 2e1a7b3395ac07826f97204b907b1155aa685b08..de448d93a4c26139d96b2f76d1ff873482e2c488 100644 (file)
@@ -404,8 +404,8 @@ class AttachmentTest extends TestCase
             $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=0-2010']);
             $resp->assertStreamedContent($content);
             $resp->assertHeader('Content-Length', '2005');
-            $resp->assertHeaderMissing('Content-Range');
-            $resp->assertStatus(200);
+            $resp->assertHeader('Content-Range', 'bytes 0-2004/2005');
+            $resp->assertStatus(206);
 
             // Range start before end
             $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=50-10']);
@@ -413,6 +413,13 @@ class AttachmentTest extends TestCase
             $resp->assertHeader('Content-Length', '2005');
             $resp->assertHeader('Content-Range', 'bytes */2005');
             $resp->assertStatus(416);
+
+            // Full range request
+            $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=0-']);
+            $resp->assertStreamedContent($content);
+            $resp->assertHeader('Content-Length', '2005');
+            $resp->assertHeader('Content-Range', 'bytes 0-2004/2005');
+            $resp->assertStatus(206);
         }
 
         $this->files->deleteAllAttachmentFiles();