diff --git a/README.md b/README.md index 417da96..b691311 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Then, run the Contao install tool to update the database. - [DcaRelations](docs/DcaRelations.md) - [DoctrineOrm](docs/DoctrineOrm.md) - [Form](docs/Form.md) +- [FileUploadNormalizer](docs/FileUploadNormalizer.md) - [Formatter](docs/Formatter.md) - [StringParser](docs/StringParser.md) - [Undo](docs/Undo.md) diff --git a/composer.json b/composer.json index dd8098f..bdb7887 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ }, "require": { "php": "^8.1", - "contao/core-bundle": "^4.13 || ^5.0" + "contao/core-bundle": "^4.13 || ^5.0", + "symfony/mime": "^6.0 || ^7.0" }, "require-dev": { "contao/manager-plugin": "^2.0", diff --git a/config/services.yml b/config/services.yml index 1460f4b..d1f7b92 100644 --- a/config/services.yml +++ b/config/services.yml @@ -2,6 +2,8 @@ services: _defaults: autowire: true autoconfigure: true + bind: + string $projectDir: '%kernel.project_dir%' # AjaxReload Codefog\HasteBundle\AjaxReloadManager: @@ -49,3 +51,6 @@ services: # UrlParser Codefog\HasteBundle\UrlParser: public: true + + # FileUploadNormalizer + Codefog\HasteBundle\FileUploadNormalizer: ~ diff --git a/docs/FileUploadNormalizer.md b/docs/FileUploadNormalizer.md new file mode 100644 index 0000000..79ab0c9 --- /dev/null +++ b/docs/FileUploadNormalizer.md @@ -0,0 +1,35 @@ +# FileUploadNormalizer + +The problem the `FileUploadNormalizer` tries to tackle is that Contao's file uploads in the form generator (`Form.php`) +accesses file uploads from the widget itself and there is no defined API. The built-in upload form field generates a +typical PHP upload array. Some form field widgets return a Contao Dbafs UUID, others just a file path and some even +return multiple values. It's a mess. + +It is designed to be used with the `processFormData` hook specifically. + +## Usage + +```php + +use Codefog\HasteBundle\FileUploadNormalizer; +use Contao\CoreBundle\DependencyInjection\Attribute\AsHook; +use Contao\Form; +use Contao\Widget; + +#[AsHook('prepareFormData')] +class PrepareFomDataListener +{ + public function __construct(private readonly FileUploadNormalizer $fileUploadNormalizer) + { + } + + /** + * @param array $fields + */ + public function __invoke(array $submitted, array $labels, array $fields, Form $form, array $files): void + { + // You now have an array of normalized files. + $normalizedFiles = $this->fileUploadNormalizer->normalize($files); + } +} +``` diff --git a/src/FileUploadNormalizer.php b/src/FileUploadNormalizer.php new file mode 100644 index 0000000..9579ef6 --- /dev/null +++ b/src/FileUploadNormalizer.php @@ -0,0 +1,179 @@ +> + */ + public function normalize(array $files): array + { + $standardizedPerKey = []; + + foreach ($files as $k => $file) { + switch (true) { + case $this->hasRequiredKeys($file): + $file['stream'] = $this->fopen($file['tmp_name']); + $file['uploaded'] ??= true; + $standardizedPerKey[$k][] = $file; + break; + case $this->isPhpUpload($file): + $standardizedPerKey[$k][] = $this->fromPhpUpload($file); + break; + case \is_array($file): + foreach ($this->normalize($file) as $nestedFiles) { + $standardizedPerKey[$k] = array_merge($standardizedPerKey[$k] ?? [], $nestedFiles); + } + break; + case null !== ($uuid = $this->extractUuid($file)): + $standardizedPerKey[$k][] = $this->fromUuid($uuid); + break; + case null !== ($filePath = $this->extractFilePath($file)): + $standardizedPerKey[$k][] = $this->fromFile($filePath); + break; + } + } + + return $standardizedPerKey; + } + + private function fromFile(string $file): array + { + return [ + 'name' => basename($file), + 'type' => $this->mimeTypeGuesser->guessMimeType($file), + 'tmp_name' => $file, + 'error' => 0, + 'size' => false === ($size = filesize($file)) ? 0 : $size, + 'uploaded' => true, + 'uuid' => null, + 'stream' => $this->fopen($file), + ]; + } + + private function fromUuid(Uuid $uuid): array + { + $item = $this->filesStorage->get($uuid); + + if (null === $item) { + return []; + } + + return [ + 'name' => $item->getName(), + 'type' => $item->getMimeType(), + 'tmp_name' => $item->getPath(), + 'error' => 0, + 'size' => $item->getFileSize(), + 'uploaded' => true, + 'uuid' => $uuid->toRfc4122(), + 'stream' => $this->filesStorage->readStream($uuid), + ]; + } + + private function hasRequiredKeys(mixed $file): bool + { + if (!\is_array($file)) { + return false; + } + + return [] === array_diff(self::REQUIRED_KEYS, array_keys($file)); + } + + private function extractUuid(mixed $candidate): Uuid|null + { + if (!Validator::isUuid($candidate)) { + return null; + } + + if (Validator::isBinaryUuid($candidate)) { + $candidate = StringUtil::binToUuid($candidate); + } + + try { + return Uuid::isValid($candidate) ? Uuid::fromString($candidate) : Uuid::fromBinary($candidate); + } catch (\Throwable) { + return null; + } + } + + private function extractFilePath(mixed $file): string|null + { + if (!\is_string($file)) { + return null; + } + + $file = Path::makeAbsolute($file, $this->projectDir); + + if (!(new Filesystem())->exists($file)) { + return null; + } + + return $file; + } + + /** + * @return resource|null + */ + private function fopen(string $file) + { + try { + $handle = @fopen($file, 'r'); + } catch (\Throwable) { + return null; + } + + if (false === $handle) { + return null; + } + + return $handle; + } + + private function isPhpUpload(mixed $file): bool + { + if (!\is_array($file) || !isset($file['tmp_name'])) { + return false; + } + + return is_uploaded_file($file['tmp_name']); + } + + private function fromPhpUpload(array $file): array + { + return [ + 'name' => $file['name'], + 'type' => $file['type'], + 'tmp_name' => $file['tmp_name'], + 'error' => 0, + 'size' => $file['size'], + 'uploaded' => true, + 'uuid' => null, + 'stream' => $this->fopen($file['tmp_name']), + ]; + } +} diff --git a/tests/FileUploadNormalizerTest.php b/tests/FileUploadNormalizerTest.php new file mode 100644 index 0000000..27be148 --- /dev/null +++ b/tests/FileUploadNormalizerTest.php @@ -0,0 +1,261 @@ +mockMimeTypeGuesserThatIsNeverCalled(); + } else { + $mimeTypeGuesser = $this->mockMimeTypeGuesserThatReturnsAType($mimeType); + } + + if (null === $filesystemItem) { + $virtualFilesystem = $this->mockFilesystemThatIsNeverCalled(); + } else { + $virtualFilesystem = $this->mockFilesystemThatReturnsAFilesystemItem($filesystemItem); + } + + $normalizer = new FileUploadNormalizer($projectDir, $mimeTypeGuesser, $virtualFilesystem); + $normalized = $normalizer->normalize($input); + + foreach ($normalized as $k => $files) { + foreach ($files as $kk => $file) { + $this->assertArrayHasKey('stream', $file); + unset($normalized[$k][$kk]['stream']); + } + } + $this->assertSame($expected, $normalized); + } + + public static function normalizeProvider(): iterable + { + yield 'Already in correct format' => [ + [ + 'upload_field' => [ + 'name' => 'name.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => 'path/to/name.jpg', + 'error' => 0, + 'size' => 333, + 'uploaded' => true, + 'uuid' => null, + ], + ], + [ + 'upload_field' => [[ + 'name' => 'name.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => 'path/to/name.jpg', + 'error' => 0, + 'size' => 333, + 'uploaded' => true, + 'uuid' => null, + ]], + ], + '/project-dir', + null, + null, + ]; + + yield 'Single UUID' => [ + [ + 'upload_field' => '660d272c-f4c3-11ed-a05b-0242ac120003', + ], + [ + 'upload_field' => [[ + 'name' => 'name.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => 'path/to/name.jpg', + 'error' => 0, + 'size' => 333, + 'uploaded' => true, + 'uuid' => '660d272c-f4c3-11ed-a05b-0242ac120003', + ]], + ], + '/project-dir', + null, + new FilesystemItem( + true, + 'path/to/name.jpg', + null, + 333, + 'image/jpeg', + ), + ]; + + yield 'Single file path' => [ + [ + 'upload_field' => __DIR__.'/Fixtures/file/name.jpg', + ], + [ + 'upload_field' => [[ + 'name' => 'name.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => Path::makeAbsolute('Fixtures/file/name.jpg', __DIR__), + 'error' => 0, + 'size' => 333, + 'uploaded' => true, + 'uuid' => null, + ]], + ], + '/project-dir', + 'image/jpeg', + null, + ]; + + yield 'Array of it all' => [ + [ + 'upload_field_already_correct' => [ + 'name' => 'name.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => 'path/to/name.jpg', + 'error' => 0, + 'size' => 333, + 'uploaded' => true, + 'uuid' => null, + ], + 'upload_field_uuid' => '660d272c-f4c3-11ed-a05b-0242ac120003', + 'upload_field_path' => __DIR__.'/Fixtures/file/name.jpg', + 'upload_multiple' => [ + [ + 'name' => 'name.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => 'path/to/name.jpg', + 'error' => 0, + 'size' => 333, + 'uploaded' => true, + 'uuid' => null, + ], + '660d272c-f4c3-11ed-a05b-0242ac120003', + __DIR__.'/Fixtures/file/name.jpg', + ], + ], + [ + 'upload_field_already_correct' => [[ + 'name' => 'name.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => 'path/to/name.jpg', + 'error' => 0, + 'size' => 333, + 'uploaded' => true, + 'uuid' => null, + ]], + 'upload_field_uuid' => [[ + 'name' => 'name.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => 'path/to/name.jpg', + 'error' => 0, + 'size' => 333, + 'uploaded' => true, + 'uuid' => '660d272c-f4c3-11ed-a05b-0242ac120003', + ]], + 'upload_field_path' => [[ + 'name' => 'name.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => Path::makeAbsolute('Fixtures/file/name.jpg', __DIR__), + 'error' => 0, + 'size' => 333, + 'uploaded' => true, + 'uuid' => null, + ]], + 'upload_multiple' => [ + [ + 'name' => 'name.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => 'path/to/name.jpg', + 'error' => 0, + 'size' => 333, + 'uploaded' => true, + 'uuid' => null, + ], [ + 'name' => 'name.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => 'path/to/name.jpg', + 'error' => 0, + 'size' => 333, + 'uploaded' => true, + 'uuid' => '660d272c-f4c3-11ed-a05b-0242ac120003', + ], [ + 'name' => 'name.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => Path::makeAbsolute('Fixtures/file/name.jpg', __DIR__), + 'error' => 0, + 'size' => 333, + 'uploaded' => true, + 'uuid' => null, + ], + ], + ], + '/project-dir', + 'image/jpeg', + new FilesystemItem( + true, + 'path/to/name.jpg', + null, + 333, + 'image/jpeg', + ), + ]; + } + + private function mockMimeTypeGuesserThatIsNeverCalled(): MimeTypeGuesserInterface + { + $mock = $this->createMock(MimeTypeGuesserInterface::class); + $mock + ->expects($this->never()) + ->method('guessMimeType') + ; + + return $mock; + } + + private function mockMimeTypeGuesserThatReturnsAType(string $type): MimeTypeGuesserInterface + { + $mock = $this->createMock(MimeTypeGuesserInterface::class); + $mock + ->expects($this->atLeastOnce()) + ->method('guessMimeType') + ->willReturn($type) + ; + + return $mock; + } + + private function mockFilesystemThatIsNeverCalled(): VirtualFilesystemInterface + { + $mock = $this->createMock(VirtualFilesystemInterface::class); + $mock + ->expects($this->never()) + ->method('get') + ; + + return $mock; + } + + private function mockFilesystemThatReturnsAFilesystemItem(FilesystemItem $item): VirtualFilesystemInterface + { + $mock = $this->createMock(VirtualFilesystemInterface::class); + $mock + ->expects($this->atLeastOnce()) + ->method('get') + ->willReturn($item) + ; + + return $mock; + } +} diff --git a/tests/Fixtures/file/name.jpg b/tests/Fixtures/file/name.jpg new file mode 100644 index 0000000..7e3bd6a Binary files /dev/null and b/tests/Fixtures/file/name.jpg differ