]> BookStack Code Mirror - bookstack/commitdiff
ZIP Imports: Added parent and permission check pre-import
authorDan Brown <redacted>
Tue, 5 Nov 2024 15:41:58 +0000 (15:41 +0000)
committerDan Brown <redacted>
Tue, 5 Nov 2024 15:41:58 +0000 (15:41 +0000)
app/Exceptions/ZipImportException.php [new file with mode: 0644]
app/Exports/Controllers/ImportController.php
app/Exports/ImportRepo.php
app/Exports/ZipExports/ZipExportValidator.php
app/Exports/ZipExports/ZipImportRunner.php [new file with mode: 0644]
app/Uploads/FileStorage.php

diff --git a/app/Exceptions/ZipImportException.php b/app/Exceptions/ZipImportException.php
new file mode 100644 (file)
index 0000000..2403c51
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace BookStack\Exceptions;
+
+class ZipImportException extends \Exception
+{
+    public function __construct(
+        public array $errors
+    ) {
+        parent::__construct();
+    }
+}
index 3a56ed0345647a67c8979576115437275ea330a6..ec5ac80808bd84ffb2127010bee820740e70efec 100644 (file)
@@ -65,8 +65,6 @@ class ImportController extends Controller
     {
         $import = $this->imports->findVisible($id);
 
-//        dd($import->decodeMetadata());
-
         $this->setPageTitle(trans('entities.import_continue'));
 
         return view('exports.import-show', [
index 3265e1c80dc006bded4f3e030e202bc2542d25a7..b94563545a482a86e1c07afe38f6f796e1ef34aa 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Exports;
 
+use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Exceptions\FileUploadException;
 use BookStack\Exceptions\ZipExportException;
 use BookStack\Exceptions\ZipValidationException;
@@ -10,6 +11,7 @@ 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\Uploads\FileStorage;
 use Illuminate\Database\Eloquent\Collection;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -18,6 +20,8 @@ class ImportRepo
 {
     public function __construct(
         protected FileStorage $storage,
+        protected ZipImportRunner $importer,
+        protected EntityQueries $entityQueries,
     ) {
     }
 
@@ -54,13 +58,13 @@ class ImportRepo
     public function storeFromUpload(UploadedFile $file): Import
     {
         $zipPath = $file->getRealPath();
+        $reader = new ZipExportReader($zipPath);
 
-        $errors = (new ZipExportValidator($zipPath))->validate();
+        $errors = (new ZipExportValidator($reader))->validate();
         if ($errors) {
             throw new ZipValidationException($errors);
         }
 
-        $reader = new ZipExportReader($zipPath);
         $exportModel = $reader->decodeDataToExportModel();
 
         $import = new Import();
@@ -90,11 +94,17 @@ class ImportRepo
         return $import;
     }
 
+    /**
+     * @throws ZipValidationException
+     */
     public function runImport(Import $import, ?string $parent = null)
     {
-        // TODO - Download import zip (if needed)
-        // TODO - Validate zip file again
-        // TODO - Check permissions before (create for main item, create for children, create for related items [image, attachments])
+        $parentModel = null;
+        if ($import->type === 'page' || $import->type === 'chapter') {
+            $parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null;
+        }
+
+        return $this->importer->run($import, $parentModel);
     }
 
     public function deleteImport(Import $import): void
index e27ae53c774348d09d2654b17c63334c0341035f..889804f201390970a5ddd1a01eb8313b9e670358 100644 (file)
@@ -10,20 +10,19 @@ use BookStack\Exports\ZipExports\Models\ZipExportPage;
 class ZipExportValidator
 {
     public function __construct(
-        protected string $zipPath,
+        protected ZipExportReader $reader,
     ) {
     }
 
     public function validate(): array
     {
-        $reader = new ZipExportReader($this->zipPath);
         try {
-            $importData = $reader->readData();
+            $importData = $this->reader->readData();
         } catch (ZipExportException $exception) {
             return ['format' => $exception->getMessage()];
         }
 
-        $helper = new ZipValidationHelper($reader);
+        $helper = new ZipValidationHelper($this->reader);
 
         if (isset($importData['book'])) {
             $modelErrors = ZipExportBook::validate($helper, $importData['book']);
diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php
new file mode 100644 (file)
index 0000000..2f784eb
--- /dev/null
@@ -0,0 +1,143 @@
+<?php
+
+namespace BookStack\Exports\ZipExports;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Exceptions\ZipExportException;
+use BookStack\Exceptions\ZipImportException;
+use BookStack\Exports\Import;
+use BookStack\Exports\ZipExports\Models\ZipExportBook;
+use BookStack\Exports\ZipExports\Models\ZipExportChapter;
+use BookStack\Exports\ZipExports\Models\ZipExportPage;
+use BookStack\Uploads\FileStorage;
+
+class ZipImportRunner
+{
+    public function __construct(
+        protected FileStorage $storage,
+    ) {
+    }
+
+    /**
+     * @throws ZipImportException
+     */
+    public function run(Import $import, ?Entity $parent = null): void
+    {
+        $zipPath = $this->getZipPath($import);
+        $reader = new ZipExportReader($zipPath);
+
+        $errors = (new ZipExportValidator($reader))->validate();
+        if ($errors) {
+            throw new ZipImportException(["ZIP failed to validate"]);
+        }
+
+        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);
+
+        // TODO - Run import
+    }
+
+    /**
+     * @throws ZipImportException
+     */
+    protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter|ZipExportBook $exportModel, Book|Chapter|null $parent = null): void
+    {
+        $errors = [];
+
+        // TODO - Extract messages to language files
+        // TODO - Ensure these are shown to users on failure
+
+        $chapters = [];
+        $pages = [];
+        $images = [];
+        $attachments = [];
+
+        if ($exportModel instanceof ZipExportBook) {
+            if (!userCan('book-create-all')) {
+                $errors[] = 'You are lacking the required permission to create 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[] = 'You are lacking the required permission to create 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[] = 'You are lacking the required permission to create pages.';
+                }
+            } else {
+                $hasPermission = userCan('page-create-all') || userCan('page-create-own');
+                if (!$hasPermission) {
+                    $errors[] = 'You are lacking the required permission to create pages.';
+                }
+            }
+        }
+
+        if (count($images) > 0) {
+            if (!userCan('image-create-all')) {
+                $errors[] = 'You are lacking the required permissions to create images.';
+            }
+        }
+
+        if (count($attachments) > 0) {
+            if (userCan('attachment-create-all')) {
+                $errors[] = 'You are lacking the required permissions to create 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);
+
+        return $tempFilePath;
+    }
+}
index 278484e519de0629b8a29c0c5d4ee98a158b6b71..e6ac368d000c20cd33c4b038438c5cc12228ec7a 100644 (file)
@@ -5,6 +5,7 @@ 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;
@@ -70,6 +71,26 @@ class FileStorage
         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.
      */
@@ -83,7 +104,7 @@ class FileStorage
      */
     protected function getStorageDiskName(): string
     {
-        $storageType = config('filesystems.attachments');
+        $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.