ZIP Exports: Got zip format validation functionally complete
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled

This commit is contained in:
Dan Brown 2024-10-30 15:26:23 +00:00
parent b50b7b667d
commit c4ec50d437
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
12 changed files with 149 additions and 42 deletions

View File

@ -1,12 +0,0 @@
<?php
namespace BookStack\Exceptions;
class ZipExportValidationException extends \Exception
{
public function __construct(
public array $errors,
) {
parent::__construct();
}
}

View File

@ -2,6 +2,7 @@
namespace BookStack\Exports\Controllers; namespace BookStack\Exports\Controllers;
use BookStack\Exports\ZipExports\ZipExportValidator;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -26,7 +27,13 @@ class ImportController extends Controller
]); ]);
$file = $request->file('file'); $file = $request->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 - Read existing ZIP upload and send through validator
// TODO - If invalid, return user with errors // TODO - If invalid, return user with errors
// TODO - Upload to storage // TODO - Upload to storage

View File

@ -47,6 +47,6 @@ class ZipExportAttachment extends ZipExportModel
'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()],
]; ];
return $context->validateArray($data, $rules); return $context->validateData($data, $rules);
} }
} }

View File

@ -6,6 +6,7 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Exports\ZipExports\ZipExportFiles; use BookStack\Exports\ZipExports\ZipExportFiles;
use BookStack\Exports\ZipExports\ZipValidationHelper;
class ZipExportBook extends ZipExportModel class ZipExportBook extends ZipExportModel
{ {
@ -50,4 +51,24 @@ class ZipExportBook extends ZipExportModel
return $instance; 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;
}
} }

View File

@ -5,6 +5,7 @@ namespace BookStack\Exports\ZipExports\Models;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Exports\ZipExports\ZipExportFiles; use BookStack\Exports\ZipExports\ZipExportFiles;
use BookStack\Exports\ZipExports\ZipValidationHelper;
class ZipExportChapter extends ZipExportModel class ZipExportChapter extends ZipExportModel
{ {
@ -42,4 +43,22 @@ class ZipExportChapter extends ZipExportModel
return self::fromModel($chapter, $files); return self::fromModel($chapter, $files);
}, $chapterArray)); }, $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;
}
} }

View File

@ -3,7 +3,9 @@
namespace BookStack\Exports\ZipExports\Models; namespace BookStack\Exports\ZipExports\Models;
use BookStack\Exports\ZipExports\ZipExportFiles; use BookStack\Exports\ZipExports\ZipExportFiles;
use BookStack\Exports\ZipExports\ZipValidationHelper;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use Illuminate\Validation\Rule;
class ZipExportImage extends ZipExportModel class ZipExportImage extends ZipExportModel
{ {
@ -22,4 +24,16 @@ class ZipExportImage extends ZipExportModel
return $instance; 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);
}
} }

View File

@ -5,6 +5,7 @@ namespace BookStack\Exports\ZipExports\Models;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageContent;
use BookStack\Exports\ZipExports\ZipExportFiles; use BookStack\Exports\ZipExports\ZipExportFiles;
use BookStack\Exports\ZipExports\ZipValidationHelper;
class ZipExportPage extends ZipExportModel class ZipExportPage extends ZipExportModel
{ {
@ -48,4 +49,25 @@ class ZipExportPage extends ZipExportModel
return self::fromModel($page, $files); return self::fromModel($page, $files);
}, $pageArray)); }, $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;
}
} }

View File

@ -34,6 +34,6 @@ class ZipExportTag extends ZipExportModel
'order' => ['nullable', 'integer'], 'order' => ['nullable', 'integer'],
]; ];
return $context->validateArray($data, $rules); return $context->validateData($data, $rules);
} }
} }

View File

@ -2,62 +2,69 @@
namespace BookStack\Exports\ZipExports; 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; use ZipArchive;
class ZipExportValidator class ZipExportValidator
{ {
protected array $errors = [];
public function __construct( public function __construct(
protected string $zipPath, protected string $zipPath,
) { ) {
} }
/** public function validate(): array
* @throws ZipExportValidationException
*/
public function validate()
{ {
// TODO - Return type
// TODO - extract messages to translations?
// Validate file exists // Validate file exists
if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) { 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 // Validate file is valid zip
$zip = new \ZipArchive(); $zip = new \ZipArchive();
$opened = $zip->open($this->zipPath, ZipArchive::RDONLY); $opened = $zip->open($this->zipPath, ZipArchive::RDONLY);
if ($opened !== true) { if ($opened !== true) {
$this->throwErrors("Could not read ZIP file"); return ['format' => "Could not read ZIP file"];
} }
// Validate json data exists, including metadata // Validate json data exists, including metadata
$jsonData = $zip->getFromName('data.json') ?: ''; $jsonData = $zip->getFromName('data.json') ?: '';
$importData = json_decode($jsonData, true); $importData = json_decode($jsonData, true);
if (!$importData) { 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'])) { if (isset($importData['book'])) {
// TODO - Validate book $modelErrors = ZipExportBook::validate($helper, $importData['book']);
$keyPrefix = 'book';
} else if (isset($importData['chapter'])) { } else if (isset($importData['chapter'])) {
// TODO - Validate chapter $modelErrors = ZipExportChapter::validate($helper, $importData['chapter']);
$keyPrefix = 'chapter';
} else if (isset($importData['page'])) { } else if (isset($importData['page'])) {
// TODO - Validate page $modelErrors = ZipExportPage::validate($helper, $importData['page']);
$keyPrefix = 'page';
} else { } 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);
} }
/** protected function flattenModelErrors(array $errors, string $keyPrefix): array
* @throws ZipExportValidationException
*/
protected function throwErrors(...$errorsToAdd): never
{ {
array_push($this->errors, ...$errorsToAdd); $flattened = [];
throw new ZipExportValidationException($this->errors);
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;
} }
} }

View File

@ -2,6 +2,7 @@
namespace BookStack\Exports\ZipExports; namespace BookStack\Exports\ZipExports;
use BookStack\Exports\ZipExports\Models\ZipExportModel;
use Illuminate\Validation\Factory; use Illuminate\Validation\Factory;
use ZipArchive; use ZipArchive;
@ -15,9 +16,15 @@ class ZipValidationHelper
$this->validationFactory = app(Factory::class); $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 public function zipFileExists(string $name): bool
@ -29,4 +36,24 @@ class ZipValidationHelper
{ {
return new ZipFileReferenceRule($this); return new ZipFileReferenceRule($this);
} }
/**
* 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;
}
} }

View File

@ -105,7 +105,8 @@ return [
'url' => 'The :attribute format is invalid.', 'url' => 'The :attribute format is invalid.',
'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', '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 validation lines
'custom' => [ 'custom' => [

View File

@ -6,7 +6,7 @@
<main class="card content-wrap auto-height mt-xxl"> <main class="card content-wrap auto-height mt-xxl">
<h1 class="list-heading">{{ trans('entities.import') }}</h1> <h1 class="list-heading">{{ trans('entities.import') }}</h1>
<form action="{{ url('/import') }}" method="POST"> <form action="{{ url('/import') }}" enctype="multipart/form-data" method="POST">
{{ csrf_field() }} {{ csrf_field() }}
<div class="flex-container-row justify-space-between wrap gap-x-xl gap-y-s"> <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"> <p class="flex min-width-l text-muted mb-s">
@ -22,6 +22,7 @@
name="file" name="file"
id="file" id="file"
class="custom-simple-file-input"> class="custom-simple-file-input">
@include('form.errors', ['name' => 'file'])
</div> </div>
</div> </div>
</div> </div>