From c4ec50d437e52ccd831b6fb2e43baa5cf255fd1a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 30 Oct 2024 15:26:23 +0000 Subject: [PATCH] ZIP Exports: Got zip format validation functionally complete --- .../ZipExportValidationException.php | 12 ----- app/Exports/Controllers/ImportController.php | 9 +++- .../ZipExports/Models/ZipExportAttachment.php | 2 +- .../ZipExports/Models/ZipExportBook.php | 21 ++++++++ .../ZipExports/Models/ZipExportChapter.php | 19 +++++++ .../ZipExports/Models/ZipExportImage.php | 14 +++++ .../ZipExports/Models/ZipExportPage.php | 22 ++++++++ .../ZipExports/Models/ZipExportTag.php | 2 +- app/Exports/ZipExports/ZipExportValidator.php | 53 +++++++++++-------- .../ZipExports/ZipValidationHelper.php | 31 ++++++++++- lang/en/validation.php | 3 +- resources/views/exports/import.blade.php | 3 +- 12 files changed, 149 insertions(+), 42 deletions(-) delete mode 100644 app/Exceptions/ZipExportValidationException.php diff --git a/app/Exceptions/ZipExportValidationException.php b/app/Exceptions/ZipExportValidationException.php deleted file mode 100644 index 2ed567d63..000000000 --- a/app/Exceptions/ZipExportValidationException.php +++ /dev/null @@ -1,12 +0,0 @@ -file('file'); - $file->getRealPath(); + $zipPath = $file->getRealPath(); + + $errors = (new ZipExportValidator($zipPath))->validate(); + if ($errors) { + dd($errors); + } + dd('passed'); // TODO - Read existing ZIP upload and send through validator // TODO - If invalid, return user with errors // TODO - Upload to storage diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index ab1f5ab75..e586b91b0 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -47,6 +47,6 @@ class ZipExportAttachment extends ZipExportModel 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], ]; - return $context->validateArray($data, $rules); + return $context->validateData($data, $rules); } } diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 5a0c5806b..7e1f2d810 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -6,6 +6,7 @@ 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 { @@ -50,4 +51,24 @@ class ZipExportBook extends ZipExportModel return $instance; } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + '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; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php index cd5765f48..03df31b70 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -5,6 +5,7 @@ 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 { @@ -42,4 +43,22 @@ class ZipExportChapter extends ZipExportModel return self::fromModel($chapter, $files); }, $chapterArray)); } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + '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; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php index 05d828734..3388c66df 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -3,7 +3,9 @@ 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 { @@ -22,4 +24,16 @@ class ZipExportImage extends ZipExportModel return $instance; } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + 'name' => ['required', 'string', 'min:1'], + 'file' => ['required', 'string', $context->fileReferenceRule()], + 'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])], + ]; + + return $context->validateData($data, $rules); + } } diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php index 8075595f2..2c8b9a88a 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -5,6 +5,7 @@ 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 { @@ -48,4 +49,25 @@ class ZipExportPage extends ZipExportModel return self::fromModel($page, $files); }, $pageArray)); } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + '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; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php index ad17d5a33..99abb811c 100644 --- a/app/Exports/ZipExports/Models/ZipExportTag.php +++ b/app/Exports/ZipExports/Models/ZipExportTag.php @@ -34,6 +34,6 @@ class ZipExportTag extends ZipExportModel 'order' => ['nullable', 'integer'], ]; - return $context->validateArray($data, $rules); + return $context->validateData($data, $rules); } } diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php index 5ad9272de..e56394aca 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -2,62 +2,69 @@ namespace BookStack\Exports\ZipExports; -use BookStack\Exceptions\ZipExportValidationException; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; +use BookStack\Exports\ZipExports\Models\ZipExportPage; use ZipArchive; class ZipExportValidator { - protected array $errors = []; - public function __construct( protected string $zipPath, ) { } - /** - * @throws ZipExportValidationException - */ - public function validate() + public function validate(): array { - // TODO - Return type - // TODO - extract messages to translations? - // Validate file exists if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) { - $this->throwErrors("Could not read ZIP file"); + return ['format' => "Could not read ZIP file"]; } // Validate file is valid zip $zip = new \ZipArchive(); $opened = $zip->open($this->zipPath, ZipArchive::RDONLY); if ($opened !== true) { - $this->throwErrors("Could not read ZIP file"); + return ['format' => "Could not read ZIP file"]; } // Validate json data exists, including metadata $jsonData = $zip->getFromName('data.json') ?: ''; $importData = json_decode($jsonData, true); if (!$importData) { - $this->throwErrors("Could not decode ZIP data.json content"); + return ['format' => "Could not find and decode ZIP data.json content"]; } + $helper = new ZipValidationHelper($zip); + if (isset($importData['book'])) { - // TODO - Validate book + $modelErrors = ZipExportBook::validate($helper, $importData['book']); + $keyPrefix = 'book'; } else if (isset($importData['chapter'])) { - // TODO - Validate chapter + $modelErrors = ZipExportChapter::validate($helper, $importData['chapter']); + $keyPrefix = 'chapter'; } else if (isset($importData['page'])) { - // TODO - Validate page + $modelErrors = ZipExportPage::validate($helper, $importData['page']); + $keyPrefix = 'page'; } else { - $this->throwErrors("ZIP file has no book, chapter or page data"); + return ['format' => "ZIP file has no book, chapter or page data"]; } + + return $this->flattenModelErrors($modelErrors, $keyPrefix); } - /** - * @throws ZipExportValidationException - */ - protected function throwErrors(...$errorsToAdd): never + protected function flattenModelErrors(array $errors, string $keyPrefix): array { - array_push($this->errors, ...$errorsToAdd); - throw new ZipExportValidationException($this->errors); + $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/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php index dd41e6f8b..8c285deaf 100644 --- a/app/Exports/ZipExports/ZipValidationHelper.php +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -2,6 +2,7 @@ namespace BookStack\Exports\ZipExports; +use BookStack\Exports\ZipExports\Models\ZipExportModel; use Illuminate\Validation\Factory; use ZipArchive; @@ -15,9 +16,15 @@ class ZipValidationHelper $this->validationFactory = app(Factory::class); } - public function validateArray(array $data, array $rules): array + public function validateData(array $data, array $rules): array { - return $this->validationFactory->make($data, $rules)->errors()->messages(); + $messages = $this->validationFactory->make($data, $rules)->errors()->messages(); + + foreach ($messages as $key => $message) { + $messages[$key] = implode("\n", $message); + } + + return $messages; } public function zipFileExists(string $name): bool @@ -29,4 +36,24 @@ class ZipValidationHelper { return new ZipFileReferenceRule($this); } + + /** + * Validate an array of relation data arrays that are expected + * to be for the given ZipExportModel. + * @param class-string $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; + } } diff --git a/lang/en/validation.php b/lang/en/validation.php index 6971edc02..9cf5d78b6 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -105,7 +105,8 @@ 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' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_model_expected' => 'Data object expected but ":type" found', // Custom validation lines 'custom' => [ diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php index 9fe596d88..15f33e6b7 100644 --- a/resources/views/exports/import.blade.php +++ b/resources/views/exports/import.blade.php @@ -6,7 +6,7 @@

{{ trans('entities.import') }}

-
+ {{ csrf_field() }}

@@ -22,6 +22,7 @@ name="file" id="file" class="custom-simple-file-input"> + @include('form.errors', ['name' => 'file'])