Skip to content

Commit

Permalink
ZIP Imports: Added image type validation/handling
Browse files Browse the repository at this point in the history
Images were missing their extension after import since it was
(potentially) not part of the import data.
This adds validation via mime sniffing (to match normal image upload
checks) and also uses the same logic to sniff out a correct extension.

Added tests to cover.
Also fixed some existing tests around zip functionality.
  • Loading branch information
ssddanbrown committed Nov 18, 2024
1 parent e2f6e50 commit 59cfc08
Show file tree
Hide file tree
Showing 9 changed files with 92 additions and 10 deletions.
3 changes: 2 additions & 1 deletion app/Exports/ZipExports/Models/ZipExportImage.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ public function metadataOnly(): void

public static function validate(ZipValidationHelper $context, array $data): array
{
$acceptedImageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
$rules = [
'id' => ['nullable', 'int', $context->uniqueIdRule('image')],
'name' => ['required', 'string', 'min:1'],
'file' => ['required', 'string', $context->fileReferenceRule()],
'file' => ['required', 'string', $context->fileReferenceRule($acceptedImageTypes)],
'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])],
];

Expand Down
12 changes: 12 additions & 0 deletions app/Exports/ZipExports/ZipExportReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportModel;
use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Util\WebSafeMimeSniffer;
use ZipArchive;

class ZipExportReader
Expand Down Expand Up @@ -81,6 +82,17 @@ public function streamFile(string $fileName)
return $this->zip->getStream("files/{$fileName}");
}

/**
* Sniff the mime type from the file of given name.
*/
public function sniffFileMime(string $fileName): string
{
$stream = $this->streamFile($fileName);
$sniffContent = fread($stream, 2000);

return (new WebSafeMimeSniffer())->sniff($sniffContent);
}

/**
* @throws ZipExportException
*/
Expand Down
12 changes: 12 additions & 0 deletions app/Exports/ZipExports/ZipFileReferenceRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class ZipFileReferenceRule implements ValidationRule
{
public function __construct(
protected ZipValidationHelper $context,
protected array $acceptedMimes,
) {
}

Expand All @@ -21,5 +22,16 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
if (!$this->context->zipReader->fileExists($value)) {
$fail('validation.zip_file')->translate();
}

if (!empty($this->acceptedMimes)) {
$fileMime = $this->context->zipReader->sniffFileMime($value);
if (!in_array($fileMime, $this->acceptedMimes)) {
$fail('validation.zip_file_mime')->translate([
'attribute' => $attribute,
'validTypes' => implode(',', $this->acceptedMimes),
'foundType' => $fileMime
]);
}
}
}
}
8 changes: 7 additions & 1 deletion app/Exports/ZipExports/ZipImportRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ protected function importAttachment(ZipExportAttachment $exportAttachment, Page

protected function importImage(ZipExportImage $exportImage, Page $page, ZipExportReader $reader): Image
{
$mime = $reader->sniffFileMime($exportImage->file);
$extension = explode('/', $mime)[1];

$file = $this->zipFileToUploadedFile($exportImage->file, $reader);
$image = $this->imageService->saveNewFromUpload(
$file,
Expand All @@ -236,9 +239,12 @@ protected function importImage(ZipExportImage $exportImage, Page $page, ZipExpor
null,
null,
true,
$exportImage->name,
$exportImage->name . '.' . $extension,
);

$image->name = $exportImage->name;
$image->save();

$this->references->addImage($image, $exportImage->id);

return $image;
Expand Down
6 changes: 3 additions & 3 deletions app/Exports/ZipExports/ZipValidationHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,17 @@ public function validateData(array $data, array $rules): array
return $messages;
}

public function fileReferenceRule(): ZipFileReferenceRule
public function fileReferenceRule(array $acceptedMimes = []): ZipFileReferenceRule
{
return new ZipFileReferenceRule($this);
return new ZipFileReferenceRule($this, $acceptedMimes);
}

public function uniqueIdRule(string $type): ZipUniqueIdRule
{
return new ZipUniqueIdRule($this, $type);
}

public function hasIdBeenUsed(string $type, int $id): bool
public function hasIdBeenUsed(string $type, mixed $id): bool
{
$key = $type . ':' . $id;
if (isset($this->validatedIds[$key])) {
Expand Down
1 change: 1 addition & 0 deletions lang/en/validation.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
'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_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.',
'zip_model_expected' => 'Data object expected but ":type" found.',
'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.',

Expand Down
4 changes: 0 additions & 4 deletions tests/Exports/ZipExportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,10 @@ public function test_page_export_with_tags()
[
'name' => 'Exporty',
'value' => 'Content',
'order' => 1,
],
[
'name' => 'Another',
'value' => '',
'order' => 2,
]
], $pageData['tags']);
}
Expand Down Expand Up @@ -162,7 +160,6 @@ public function test_page_export_file_attachments()
$attachmentData = $pageData['attachments'][0];
$this->assertEquals('PageAttachmentExport.txt', $attachmentData['name']);
$this->assertEquals($attachment->id, $attachmentData['id']);
$this->assertEquals(1, $attachmentData['order']);
$this->assertArrayNotHasKey('link', $attachmentData);
$this->assertNotEmpty($attachmentData['file']);

Expand Down Expand Up @@ -193,7 +190,6 @@ public function test_page_export_link_attachments()
$attachmentData = $pageData['attachments'][0];
$this->assertEquals('My link attachment for export', $attachmentData['name']);
$this->assertEquals($attachment->id, $attachmentData['id']);
$this->assertEquals(1, $attachmentData['order']);
$this->assertEquals('https://example.com/cats', $attachmentData['link']);
$this->assertArrayNotHasKey('file', $attachmentData);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
use BookStack\Uploads\Image;
use Tests\TestCase;

class ZipExportValidatorTests extends TestCase
class ZipExportValidatorTest extends TestCase
{
protected array $filesToRemove = [];

Expand Down Expand Up @@ -71,4 +71,23 @@ public function test_ids_have_to_be_unique()
$this->assertEquals($expectedMessage, $results['book.pages.1.id']);
$this->assertEquals($expectedMessage, $results['book.chapters.1.id']);
}

public function test_image_files_need_to_be_a_valid_detected_image_file()
{
$validator = $this->getValidatorForData([
'page' => [
'id' => 4,
'name' => 'My page',
'markdown' => 'hello',
'images' => [
['id' => 4, 'name' => 'Image A', 'type' => 'gallery', 'file' => 'cat'],
],
]
], ['cat' => $this->files->testFilePath('test-file.txt')]);

$results = $validator->validate();
$this->assertCount(1, $results);

$this->assertEquals('The file needs to reference a file of type image/png,image/jpeg,image/gif,image/webp, found text/plain.', $results['page.images.0.file']);
}
}
35 changes: 35 additions & 0 deletions tests/Exports/ZipImportRunnerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -358,4 +358,39 @@ public function test_revert_cleans_up_uploaded_files()

ZipTestHelper::deleteZipForImport($import);
}

public function test_imported_images_have_their_detected_extension_added()
{
$testImagePath = $this->files->testFilePath('test-image.png');
$parent = $this->entities->chapter();

$import = ZipTestHelper::importFromData([], [
'page' => [
'name' => 'Page A',
'html' => '<p>hello</p>',
'images' => [
[
'id' => 2,
'name' => 'Cat',
'type' => 'gallery',
'file' => 'cat_image'
]
],
],
], [
'cat_image' => $testImagePath,
]);

$this->asAdmin();
/** @var Page $page */
$page = $this->runner->run($import, $parent);

$pageImages = Image::where('uploaded_to', '=', $page->id)->whereIn('type', ['gallery', 'drawio'])->get();

$this->assertCount(1, $pageImages);
$this->assertStringEndsWith('.png', $pageImages[0]->url);
$this->assertStringEndsWith('.png', $pageImages[0]->path);

ZipTestHelper::deleteZipForImport($import);
}
}

0 comments on commit 59cfc08

Please sign in to comment.