]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'portazips' into development
authorDan Brown <redacted>
Sun, 1 Dec 2024 13:06:43 +0000 (13:06 +0000)
committerDan Brown <redacted>
Sun, 1 Dec 2024 13:06:43 +0000 (13:06 +0000)
84 files changed:
app/Activity/ActivityType.php
app/Entities/Models/Chapter.php
app/Entities/Repos/PageRepo.php
app/Entities/Tools/Cloner.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/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
composer.json
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/docs/portable-zip-file-format.md [new file with mode: 0644]
lang/en/activities.php
lang/en/entities.php
lang/en/errors.php
lang/en/settings.php
lang/en/validation.php
resources/js/components/index.ts
resources/js/components/loading-button.ts [new file with mode: 0644]
resources/js/components/page-comments.js
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/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]

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,
+    ) {
     }
 
     /**
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 beddfe8e6e0f08cb6e0f373a6d46d202a9b6056e..4f78830b075cb49f053d88672610ff365684abed 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);
     }
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 b9b5c9a049048bbb9e7889f13aa557729d787d8a..b8d8da9e72ff0c732e3572165c91942fd040b5cb 100644 (file)
@@ -16,6 +16,7 @@
         "ext-json": "*",
         "ext-mbstring": "*",
         "ext-xml": "*",
+        "ext-zip": "*",
         "bacon/bacon-qr-code": "^3.0",
         "doctrine/dbal": "^3.5",
         "dompdf/dompdf": "^3.0",
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();
+    }
+};
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 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 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 3d7e1365f30946e5d577787b6f251e306496900e..8f023836b090876360c120df95e3c1f0a137bf18 100644 (file)
@@ -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 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']);
diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php
deleted file mode 100644 (file)
index 7aafa3b..0000000
+++ /dev/null
@@ -1,569 +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();
-        $page = $chapter->pages()->first();
-        $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown'));
-
-        $resp->assertSee('# ' . $chapter->name);
-        $resp->assertSee('# ' . $page->name);
-    }
-
-    public function test_book_markdown_export()
-    {
-        $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
-        $chapter = $book->chapters()->first();
-        $page = $chapter->pages()->first();
-        $resp = $this->asEditor()->get($book->getUrl('/export/markdown'));
-
-        $resp->assertSee('# ' . $book->name);
-        $resp->assertSee('# ' . $chapter->name);
-        $resp->assertSee('# ' . $page->name);
-    }
-
-    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..05ebbc6
--- /dev/null
@@ -0,0 +1,85 @@
+<?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();
+        $page = $chapter->pages()->first();
+        $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown'));
+
+        $resp->assertSee('# ' . $chapter->name);
+        $resp->assertSee('# ' . $page->name);
+    }
+
+    public function test_book_markdown_export()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
+        $chapter = $book->chapters()->first();
+        $page = $chapter->pages()->first();
+        $resp = $this->asEditor()->get($book->getUrl('/export/markdown'));
+
+        $resp->assertSee('# ' . $book->name);
+        $resp->assertSee('# ' . $chapter->name);
+        $resp->assertSee('# ' . $page->name);
+    }
+
+    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);
+    }
+}