diff --git a/app/Exceptions/ZipExportValidationException.php b/app/Exceptions/ZipExportValidationException.php new file mode 100644 index 000000000..2ed567d63 --- /dev/null +++ b/app/Exceptions/ZipExportValidationException.php @@ -0,0 +1,12 @@ +validate($request, [ + 'file' => ['required', 'file'] + ]); + + $file = $request->file('file'); + $file->getRealPath(); // 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 283ffa751..ab1f5ab75 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -3,6 +3,7 @@ namespace BookStack\Exports\ZipExports\Models; use BookStack\Exports\ZipExports\ZipExportFiles; +use BookStack\Exports\ZipExports\ZipValidationHelper; use BookStack\Uploads\Attachment; class ZipExportAttachment extends ZipExportModel @@ -35,4 +36,17 @@ class ZipExportAttachment extends ZipExportModel return self::fromModel($attachment, $files); }, $attachmentArray)); } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + 'name' => ['required', 'string', 'min:1'], + 'order' => ['nullable', 'integer'], + 'link' => ['required_without:file', 'nullable', 'string'], + 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], + ]; + + return $context->validateArray($data, $rules); + } } diff --git a/app/Exports/ZipExports/Models/ZipExportModel.php b/app/Exports/ZipExports/Models/ZipExportModel.php index 8d0c0a437..4d66f010f 100644 --- a/app/Exports/ZipExports/Models/ZipExportModel.php +++ b/app/Exports/ZipExports/Models/ZipExportModel.php @@ -2,6 +2,7 @@ namespace BookStack\Exports\ZipExports\Models; +use BookStack\Exports\ZipExports\ZipValidationHelper; use JsonSerializable; abstract class ZipExportModel implements JsonSerializable @@ -17,4 +18,12 @@ abstract class ZipExportModel implements JsonSerializable $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; } diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php index d4e3c4290..ad17d5a33 100644 --- a/app/Exports/ZipExports/Models/ZipExportTag.php +++ b/app/Exports/ZipExports/Models/ZipExportTag.php @@ -3,6 +3,7 @@ namespace BookStack\Exports\ZipExports\Models; use BookStack\Activity\Models\Tag; +use BookStack\Exports\ZipExports\ZipValidationHelper; class ZipExportTag extends ZipExportModel { @@ -24,4 +25,15 @@ class ZipExportTag extends ZipExportModel { 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'], + 'order' => ['nullable', 'integer'], + ]; + + return $context->validateArray($data, $rules); + } } diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php new file mode 100644 index 000000000..5ad9272de --- /dev/null +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -0,0 +1,63 @@ +zipPath) || !is_readable($this->zipPath)) { + $this->throwErrors("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"); + } + + // 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"); + } + + if (isset($importData['book'])) { + // TODO - Validate book + } else if (isset($importData['chapter'])) { + // TODO - Validate chapter + } else if (isset($importData['page'])) { + // TODO - Validate page + } else { + $this->throwErrors("ZIP file has no book, chapter or page data"); + } + } + + /** + * @throws ZipExportValidationException + */ + protected function throwErrors(...$errorsToAdd): never + { + array_push($this->errors, ...$errorsToAdd); + throw new ZipExportValidationException($this->errors); + } +} diff --git a/app/Exports/ZipExports/ZipFileReferenceRule.php b/app/Exports/ZipExports/ZipFileReferenceRule.php new file mode 100644 index 000000000..4f942e0e7 --- /dev/null +++ b/app/Exports/ZipExports/ZipFileReferenceRule.php @@ -0,0 +1,26 @@ +context->zipFileExists($value)) { + $fail('validation.zip_file')->translate(); + } + } +} diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php new file mode 100644 index 000000000..dd41e6f8b --- /dev/null +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -0,0 +1,32 @@ +validationFactory = app(Factory::class); + } + + public function validateArray(array $data, array $rules): array + { + return $this->validationFactory->make($data, $rules)->errors()->messages(); + } + + public function zipFileExists(string $name): bool + { + return $this->zip->statName("files/{$name}") !== false; + } + + public function fileReferenceRule(): ZipFileReferenceRule + { + return new ZipFileReferenceRule($this); + } +} diff --git a/lang/en/validation.php b/lang/en/validation.php index 2a676c7c4..6971edc02 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -105,6 +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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php index b7030f114..9fe596d88 100644 --- a/resources/views/exports/import.blade.php +++ b/resources/views/exports/import.blade.php @@ -10,7 +10,7 @@ {{ csrf_field() }}
- Import content using a portable zip export from the same, or a different, instance. + Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to import then press "Validate Import" to proceed. After the file has been uploaded and validated you'll be able to configure & confirm the import in the next view.