Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introducing the FileUploadNormalizer #221

Merged
merged 2 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ services:
_defaults:
autowire: true
autoconfigure: true
bind:
string $projectDir: '%kernel.project_dir%'

# AjaxReload
Codefog\HasteBundle\AjaxReloadManager:
Expand Down Expand Up @@ -49,3 +51,6 @@ services:
# UrlParser
Codefog\HasteBundle\UrlParser:
public: true

# FileUploadNormalizer
Codefog\HasteBundle\FileUploadNormalizer: ~
35 changes: 35 additions & 0 deletions docs/FileUploadNormalizer.md
Original file line number Diff line number Diff line change
@@ -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<Widget> $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);
}
}
```
179 changes: 179 additions & 0 deletions src/FileUploadNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php

declare(strict_types=1);

namespace Codefog\HasteBundle;

use Contao\CoreBundle\Filesystem\VirtualFilesystemInterface;
use Contao\StringUtil;
use Contao\Validator;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\Mime\MimeTypeGuesserInterface;
use Symfony\Component\Uid\Uuid;

class FileUploadNormalizer
{
private const REQUIRED_KEYS = ['name', 'type', 'tmp_name', 'error', 'size', 'uuid'];

public function __construct(
private readonly string $projectDir,
private readonly MimeTypeGuesserInterface $mimeTypeGuesser,
private readonly VirtualFilesystemInterface $filesStorage,
) {
}

/**
* This service helps to normalize file upload widget values. Some return an
* array, others just uuids, some only file paths. This method is designed to
* bring them all to the Contao FormUpload value style.
*
* @return array<string, array<array{name: string, type: string, tmp_name: string, error: int, size: int, uploaded: bool, uuid: ?string, stream: ?resource}>>
*/
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']),
];
}
}
Loading
Loading