Skip to content

Commit e2f6e50

Browse files
committed
ZIP Exports: Added ID checks and testing to validator
1 parent c2c64e2 commit e2f6e50

File tree

10 files changed

+130
-6
lines changed

10 files changed

+130
-6
lines changed

app/Exports/ZipExports/Models/ZipExportAttachment.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public static function fromModelArray(array $attachmentArray, ZipExportFiles $fi
4343
public static function validate(ZipValidationHelper $context, array $data): array
4444
{
4545
$rules = [
46-
'id' => ['nullable', 'int'],
46+
'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')],
4747
'name' => ['required', 'string', 'min:1'],
4848
'link' => ['required_without:file', 'nullable', 'string'],
4949
'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()],

app/Exports/ZipExports/Models/ZipExportBook.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public static function fromModel(Book $model, ZipExportFiles $files): self
7070
public static function validate(ZipValidationHelper $context, array $data): array
7171
{
7272
$rules = [
73-
'id' => ['nullable', 'int'],
73+
'id' => ['nullable', 'int', $context->uniqueIdRule('book')],
7474
'name' => ['required', 'string', 'min:1'],
7575
'description_html' => ['nullable', 'string'],
7676
'cover' => ['nullable', 'string', $context->fileReferenceRule()],

app/Exports/ZipExports/Models/ZipExportChapter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public static function fromModelArray(array $chapterArray, ZipExportFiles $files
5959
public static function validate(ZipValidationHelper $context, array $data): array
6060
{
6161
$rules = [
62-
'id' => ['nullable', 'int'],
62+
'id' => ['nullable', 'int', $context->uniqueIdRule('chapter')],
6363
'name' => ['required', 'string', 'min:1'],
6464
'description_html' => ['nullable', 'string'],
6565
'priority' => ['nullable', 'int'],

app/Exports/ZipExports/Models/ZipExportImage.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public function metadataOnly(): void
3333
public static function validate(ZipValidationHelper $context, array $data): array
3434
{
3535
$rules = [
36-
'id' => ['nullable', 'int'],
36+
'id' => ['nullable', 'int', $context->uniqueIdRule('image')],
3737
'name' => ['required', 'string', 'min:1'],
3838
'file' => ['required', 'string', $context->fileReferenceRule()],
3939
'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])],

app/Exports/ZipExports/Models/ZipExportPage.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public static function fromModelArray(array $pageArray, ZipExportFiles $files):
6868
public static function validate(ZipValidationHelper $context, array $data): array
6969
{
7070
$rules = [
71-
'id' => ['nullable', 'int'],
71+
'id' => ['nullable', 'int', $context->uniqueIdRule('page')],
7272
'name' => ['required', 'string', 'min:1'],
7373
'html' => ['nullable', 'string'],
7474
'markdown' => ['nullable', 'string'],

app/Exports/ZipExports/ZipFileReferenceRule.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
use Closure;
66
use Illuminate\Contracts\Validation\ValidationRule;
7-
use ZipArchive;
87

98
class ZipFileReferenceRule implements ValidationRule
109
{
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace BookStack\Exports\ZipExports;
4+
5+
use Closure;
6+
use Illuminate\Contracts\Validation\ValidationRule;
7+
8+
class ZipUniqueIdRule implements ValidationRule
9+
{
10+
public function __construct(
11+
protected ZipValidationHelper $context,
12+
protected string $modelType,
13+
) {
14+
}
15+
16+
17+
/**
18+
* @inheritDoc
19+
*/
20+
public function validate(string $attribute, mixed $value, Closure $fail): void
21+
{
22+
if ($this->context->hasIdBeenUsed($this->modelType, $value)) {
23+
$fail('validation.zip_unique')->translate(['attribute' => $attribute]);
24+
}
25+
}
26+
}

app/Exports/ZipExports/ZipValidationHelper.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ class ZipValidationHelper
99
{
1010
protected Factory $validationFactory;
1111

12+
/**
13+
* Local store of validated IDs (in format "<type>:<id>". Example: "book:2")
14+
* which we can use to check uniqueness.
15+
* @var array<string, bool>
16+
*/
17+
protected array $validatedIds = [];
18+
1219
public function __construct(
1320
public ZipExportReader $zipReader,
1421
) {
@@ -31,6 +38,23 @@ public function fileReferenceRule(): ZipFileReferenceRule
3138
return new ZipFileReferenceRule($this);
3239
}
3340

41+
public function uniqueIdRule(string $type): ZipUniqueIdRule
42+
{
43+
return new ZipUniqueIdRule($this, $type);
44+
}
45+
46+
public function hasIdBeenUsed(string $type, int $id): bool
47+
{
48+
$key = $type . ':' . $id;
49+
if (isset($this->validatedIds[$key])) {
50+
return true;
51+
}
52+
53+
$this->validatedIds[$key] = true;
54+
55+
return false;
56+
}
57+
3458
/**
3559
* Validate an array of relation data arrays that are expected
3660
* to be for the given ZipExportModel.

lang/en/validation.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107

108108
'zip_file' => 'The :attribute needs to reference a file within the ZIP.',
109109
'zip_model_expected' => 'Data object expected but ":type" found.',
110+
'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.',
110111

111112
// Custom validation lines
112113
'custom' => [
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace Tests\Exports;
4+
5+
use BookStack\Entities\Models\Book;
6+
use BookStack\Entities\Models\Chapter;
7+
use BookStack\Entities\Models\Page;
8+
use BookStack\Exports\ZipExports\ZipExportReader;
9+
use BookStack\Exports\ZipExports\ZipExportValidator;
10+
use BookStack\Exports\ZipExports\ZipImportRunner;
11+
use BookStack\Uploads\Image;
12+
use Tests\TestCase;
13+
14+
class ZipExportValidatorTests extends TestCase
15+
{
16+
protected array $filesToRemove = [];
17+
18+
protected function tearDown(): void
19+
{
20+
foreach ($this->filesToRemove as $file) {
21+
unlink($file);
22+
}
23+
24+
parent::tearDown();
25+
}
26+
27+
protected function getValidatorForData(array $zipData, array $files = []): ZipExportValidator
28+
{
29+
$upload = ZipTestHelper::zipUploadFromData($zipData, $files);
30+
$path = $upload->getRealPath();
31+
$this->filesToRemove[] = $path;
32+
$reader = new ZipExportReader($path);
33+
return new ZipExportValidator($reader);
34+
}
35+
36+
public function test_ids_have_to_be_unique()
37+
{
38+
$validator = $this->getValidatorForData([
39+
'book' => [
40+
'id' => 4,
41+
'name' => 'My book',
42+
'pages' => [
43+
[
44+
'id' => 4,
45+
'name' => 'My page',
46+
'markdown' => 'hello',
47+
'attachments' => [
48+
['id' => 4, 'name' => 'Attachment A', 'link' => 'https://example.com'],
49+
['id' => 4, 'name' => 'Attachment B', 'link' => 'https://example.com']
50+
],
51+
'images' => [
52+
['id' => 4, 'name' => 'Image A', 'type' => 'gallery', 'file' => 'cat'],
53+
['id' => 4, 'name' => 'Image b', 'type' => 'gallery', 'file' => 'cat'],
54+
],
55+
],
56+
['id' => 4, 'name' => 'My page', 'markdown' => 'hello'],
57+
],
58+
'chapters' => [
59+
['id' => 4, 'name' => 'Chapter 1'],
60+
['id' => 4, 'name' => 'Chapter 2']
61+
]
62+
]
63+
], ['cat' => $this->files->testFilePath('test-image.png')]);
64+
65+
$results = $validator->validate();
66+
$this->assertCount(4, $results);
67+
68+
$expectedMessage = 'The id must be unique for the object type within the ZIP.';
69+
$this->assertEquals($expectedMessage, $results['book.pages.0.attachments.1.id']);
70+
$this->assertEquals($expectedMessage, $results['book.pages.0.images.1.id']);
71+
$this->assertEquals($expectedMessage, $results['book.pages.1.id']);
72+
$this->assertEquals($expectedMessage, $results['book.chapters.1.id']);
73+
}
74+
}

0 commit comments

Comments
 (0)