EasyAdminBundle icon indicating copy to clipboard operation
EasyAdminBundle copied to clipboard

AWS S3 image is not showing on edit

Open fvukojevic opened this issue 1 year ago • 3 comments

I have an entity that stores images in s3. This is how I modified my field:

            ImageField::new('mainImage', 'Slika Poduzeća')
                ->setBasePath($this->getBaseUrlForS3())
                ->setUploadDir('') // Dummy - unused we use s3
                ->setRequired(false)
                ->setHelp('Glavna slika poduzeća/logo (opcionalno)')
                ->setUploadedFileNamePattern('[randomhash].[extension]')  // Customize the pattern
                ->setFormTypeOption('upload_new', function (UploadedFile $file, string $uploadDir, string $fileName) {
                    $filePath = 'pages/' . $fileName;
                    $this->s3Service->uploadFile($file, $filePath);
                    return $filePath;
                })
                ->setFormTypeOption('upload_delete', function (string $filePath) {
                    // Delete the file from S3 via S3Service
                    $this->s3Service->deleteFile($filePath);
                })
                ->setFormTypeOption('mapped', true)
                ->setFormTypeOption('data_class', null) // Ensure that the field gets populated with the existing value

Saving works nicely, image is shown on both detail and list page, but when I click on edit, the image is not shown in the image field + if I just save then, it gets deleted like I removed it. Anyone able to tell me why and how to fix this?

fvukojevic avatar Sep 04 '24 22:09 fvukojevic

Same problem for me 🤔

ousmaneNdiaye avatar Nov 06 '24 15:11 ousmaneNdiaye

Any updates?

lekseven avatar Jan 31 '25 00:01 lekseven

You can create your own FileField, I will post example

// FileField.php
<?php

declare(strict_types=1);

namespace App\UI\Admin\Field;

use App\UI\Admin\Form\Type\FileUploadType;
use EasyCorp\Bundle\EasyAdminBundle\Config\Asset;
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\TextAlign;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
use EasyCorp\Bundle\EasyAdminBundle\Field\FieldTrait;
use Symfony\Contracts\Translation\TranslatableInterface;

class FileField implements FieldInterface
{
    use FieldTrait;

    public const OPTION_UPLOAD_DIR                 = 'uploadDir';
    public const OPTION_UPLOADED_FILE_NAME_PATTERN = 'uploadedFileNamePattern';

    /**
     * @param TranslatableInterface|string|false|null $label
     */
    public static function new(string $propertyName, $label = null): self
    {
        return (new self())
            ->setProperty($propertyName)
            ->setLabel($label)
            ->setTemplateName('crud/field/image')
            ->setFormType(FileUploadType::class)
            ->addCssClass('field-image')
            ->addJsFiles(Asset::fromEasyAdminAssetPackage('field-image.js'), Asset::fromEasyAdminAssetPackage('field-file-upload.js'))
            ->setDefaultColumns('col-md-7 col-xxl-5')
            ->setTextAlign(TextAlign::CENTER)
            ->setCustomOption(self::OPTION_UPLOAD_DIR, null)
            ->setCustomOption(self::OPTION_UPLOADED_FILE_NAME_PATTERN, '[uuid].[slug].[extension]');
    }

    /**
     * Relative to project's root directory (e.g. use 'public/uploads/' for `<your-project-dir>/public/uploads/`)
     * Default upload dir: `<your-project-dir>/public/uploads/images/`.
     */
    public function setUploadDir(string $uploadDirPath): self
    {
        $this->setCustomOption(self::OPTION_UPLOAD_DIR, $uploadDirPath);

        return $this;
    }

    /**
     * @param string|\Closure $patternOrCallable
     *
     * If it's a string, uploaded files will be renamed according to the given pattern.
     * The pattern can include the following special values:
     *   [day] [month] [year] [timestamp]
     *   [name] [slug] [extension] [contenthash]
     *   [randomhash] [uuid] [ulid]
     * (e.g. [year]/[month]/[day]/[slug]-[contenthash].[extension])
     *
     * If it's a callable, you will be passed the Symfony's UploadedFile instance and you must
     * return a string with the new filename.
     * (e.g. fn(UploadedFile $file) => sprintf('upload_%d_%s.%s', random_int(1, 999), $file->getFilename(), $file->guessExtension()))
     */
    public function setUploadedFileNamePattern($patternOrCallable): self
    {
        $this->setCustomOption(self::OPTION_UPLOADED_FILE_NAME_PATTERN, $patternOrCallable);

        return $this;
    }
}
// FileConfigurator.php
<?php

namespace App\UI\Admin\Field\Configurator;

use App\UI\Admin\Field\FileField;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldConfiguratorInterface;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;

final class FileConfigurator implements FieldConfiguratorInterface
{
    public function supports(FieldDto $field, EntityDto $entityDto): bool
    {
        return FileField::class === $field->getFieldFqcn();
    }

    public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $context): void
    {
        $field->setFormTypeOption('upload_filename', $field->getCustomOption(FileField::OPTION_UPLOADED_FILE_NAME_PATTERN));
        $field->setFormTypeOption('upload_dir', $field->getCustomOption(FileField::OPTION_UPLOAD_DIR));
    }
}
// FileUploadType.php
<?php

declare(strict_types=1);

namespace App\UI\Admin\Form\Type;

use App\Infrastructure\File\AwsS3FileUploader;
use App\UI\Admin\Form\DataTransformer\StringToFileTransformer;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Model\FileUploadState;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\OptionsResolver\Exception\InvalidArgumentException;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\Uid\Ulid;

/**
 * @template T of FileUploadState
 *
 * @extends AbstractType<T>
 */
class FileUploadType extends AbstractType implements DataMapperInterface
{
    public function __construct(
        private readonly LoggerInterface $logger,
        private readonly AwsS3FileUploader $uploader,
    ) {
    }

    public function buildForm(
        FormBuilderInterface $builder,
        array $options,
    ): void {
        $uploadDir      = $options['upload_dir'];
        $uploadFilename = $options['upload_filename'];
        $uploadValidate = $options['upload_validate'];
        $allowAdd       = $options['allow_add'];
        unset(
            $options['upload_dir'],
            $options['upload_new'],
            $options['upload_delete'],
            $options['upload_filename'],
            $options['upload_validate'],
            $options['download_path'],
            $options['allow_add'],
            $options['allow_delete'],
            $options['compound'],
        );

        $builder->add('file', FileType::class, $options);
        $builder->add('delete', CheckboxType::class, ['required' => false]);

        $builder->setDataMapper($this);
        $builder->setAttribute('state', new FileUploadState($allowAdd));
        $builder->addModelTransformer(
            new StringToFileTransformer(
                $this->logger,
                $this->uploader,
                $uploadDir,
                $uploadFilename,
                $uploadValidate,
                $options['multiple'],
            ),
        );
    }

    public function buildView(
        FormView $view,
        FormInterface $form,
        array $options,
    ): void {
        /** @var FileUploadState $state */
        $state = $form->getConfig()->getAttribute('state');

        if ([] === ($currentFiles = $state->getCurrentFiles())) {
            $data = $form->getNormData();

            if (null !== $data && [] !== $data) {
                $currentFiles = \is_array($data) ? $data : [$data];

                foreach ($currentFiles as $i => $file) {
                    if ($file instanceof UploadedFile) {
                        unset($currentFiles[$i]);
                    }
                }
            }
        }

        $view->vars['currentFiles']  = $currentFiles;
        $view->vars['multiple']      = $options['multiple'];
        $view->vars['allow_add']     = $options['allow_add'];
        $view->vars['allow_delete']  = $options['allow_delete'];
        $view->vars['download_path'] = $options['download_path'];
        $view->vars['required']      = $options['required'];
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $uploader  = $this->uploader;
        $uploadNew = static function (
            UploadedFile $file,
            string $uploadDir,
            string $fileName,
        ) use ($uploader): void {
            $uploader->upload($file, $uploadDir . $fileName);
        };

        $uploadDelete = static function (File $file, string $uploadDir) use (
            $uploader,
        ): void {
            $uploader->remove($uploadDir . $file->getFilename());
        };

        $uploadFilename = static function (UploadedFile $file): string {
            return $file->getClientOriginalName();
        };

        $uploadValidate = static function (
            string $filename,
            string $uploadDir,
        ) use ($uploader): string {
            if (!$uploader->exists($uploadDir . $filename)) {
                return $filename;
            }

            $index = 1;
            /** @var array{dirname: string, extension: string, filename: string} $pathInfo */
            $pathInfo = pathinfo($filename);
            while (
                $uploader->exists(
                    $filename = sprintf(
                        '%s%s/%s_%d.%s',
                        $uploadDir,
                        $pathInfo['dirname'],
                        $pathInfo['filename'],
                        $index,
                        $pathInfo['extension'],
                    ),
                )
            ) {
                $index++;
            }

            return $filename;
        };

        $downloadPath = fn (Options $options) => $uploader->getPath(
            $options['upload_dir'],
        );

        $allowAdd = static fn (Options $options) => $options['multiple'];

        $dataClass = static fn (Options $options) => $options['multiple']
            ? null
            : File::class;

        $emptyData = static fn (Options $options) => $options['multiple']
            ? []
            : null;

        $resolver->setDefaults([
            'upload_dir'        => '/upload/',
            'upload_new'        => $uploadNew,
            'upload_delete'     => $uploadDelete,
            'upload_filename'   => $uploadFilename,
            'upload_validate'   => $uploadValidate,
            'download_path'     => $downloadPath,
            'allow_add'         => $allowAdd,
            'allow_delete'      => true,
            'data_class'        => $dataClass,
            'empty_data'        => $emptyData,
            'multiple'          => false,
            'required'          => false,
            'error_bubbling'    => false,
            'allow_file_upload' => true,
        ]);

        $resolver->setAllowedTypes('upload_dir', 'string');
        $resolver->setAllowedTypes('upload_new', 'callable');
        $resolver->setAllowedTypes('upload_delete', 'callable');
        $resolver->setAllowedTypes('upload_filename', ['string', 'callable']);
        $resolver->setAllowedTypes('upload_validate', 'callable');
        $resolver->setAllowedTypes('download_path', ['null', 'string']);
        $resolver->setAllowedTypes('allow_add', 'bool');
        $resolver->setAllowedTypes('allow_delete', 'bool');
        $resolver->setAllowedTypes('required', 'bool');

        $resolver->setNormalizer('upload_filename', static function (
            Options $options,
            $fileNamePatternOrCallable,
        ) {
            if (\is_callable($fileNamePatternOrCallable)) {
                return $fileNamePatternOrCallable;
            }

            return static function (UploadedFile $file) use (
                $fileNamePatternOrCallable,
            ) {
                return strtr($fileNamePatternOrCallable, [
                    '[contenthash]' => sha1_file($file->getRealPath()),
                    '[day]'         => date('d'),
                    '[extension]'   => $file->guessClientExtension(),
                    '[month]'       => date('m'),
                    '[name]'        => pathinfo(
                        $file->getClientOriginalName(),
                        \PATHINFO_FILENAME,
                    ),
                    '[randomhash]' => bin2hex(random_bytes(20)),
                    '[slug]'       => new AsciiSlugger()
                        ->slug(
                            pathinfo(
                                $file->getClientOriginalName(),
                                \PATHINFO_FILENAME,
                            ),
                        )
                        ->lower()
                        ->toString(),
                    '[timestamp]' => time(),
                    '[uuid]'      => Uuid::uuid4(),
                    '[ulid]'      => new Ulid(),
                    '[year]'      => date('Y'),
                ]);
            };
        });
        $resolver->setNormalizer('allow_add', static function (
            Options $options,
            string $value,
        ): bool {
            if ((bool) $value && !$options['multiple']) {
                throw new InvalidArgumentException('Setting "allow_add" option to "true" when "multiple" option is "false" is not supported.');
            }

            return (bool) $value;
        });
    }

    public function getBlockPrefix(): string
    {
        return 'ea_fileupload';
    }

    /**
     * @param \Traversable<mixed, FormInterface<FileUploadState>> $forms
     */
    public function mapDataToForms(mixed $currentFiles, $forms): void
    {
        /** @var FormInterface<FileUploadState> $fileForm */
        $fileForm = current(iterator_to_array($forms));
        $fileForm->setData($currentFiles);
    }

    /**
     * @param \Traversable<mixed, FormInterface<FileUploadState>> $forms
     */
    public function mapFormsToData($forms, mixed &$currentFiles): void
    {
        /** @var FormInterface<FileUploadState>[] $children */
        $children = iterator_to_array($forms);
        /** @var UploadedFile[] $uploadedFiles */
        $uploadedFiles = $children['file']->getData();

        /** @var FormInterface<FileUploadState> $form */
        $form = $children['file']->getParent();
        /** @var FileUploadState $state */
        $state = $form->getConfig()->getAttribute('state');
        $state->setCurrentFiles($currentFiles);
        $state->setUploadedFiles($uploadedFiles);
        /** @var bool $delete */
        $delete = $children['delete']->getData();
        $state->setDelete($delete);

        if (!$state->isModified()) {
            return;
        }

        if ($state->isAddAllowed() && !$state->isDelete()) {
            $currentFiles = array_merge($currentFiles, $uploadedFiles);
        } else {
            $currentFiles = $uploadedFiles;
        }
    }
}
// StringToFileTransformer.php
<?php

declare(strict_types=1);

namespace App\UI\Admin\Form\DataTransformer;

use App\Infrastructure\File\AwsS3FileUploader;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\File\UploadedFile;

/**
 * @implements DataTransformerInterface<mixed, mixed>
 */
class StringToFileTransformer implements DataTransformerInterface
{
    public function __construct(
        private readonly LoggerInterface $logger,
        private readonly AwsS3FileUploader $uploader,
        private readonly string $uploadDir,
        private readonly mixed $uploadFilename,
        private readonly mixed $uploadValidate,
        private readonly bool $multiple,
    ) {
    }

    public function transform(mixed $value): mixed
    {
        if (null === $value || [] === $value) {
            return null;
        }

        if (!$this->multiple) {
            return $this->doTransform($value);
        }

        if (!\is_array($value)) {
            throw new TransformationFailedException('Expected an array or null.');
        }

        return array_map([$this, 'doTransform'], $value);
    }

    public function reverseTransform(mixed $value): mixed
    {
        if (null === $value || [] === $value) {
            return null;
        }

        if (!$this->multiple) {
            return $this->doReverseTransform($value);
        }

        if (!\is_array($value)) {
            throw new TransformationFailedException('Expected an array or null.');
        }

        return array_map([$this, 'doReverseTransform'], $value);
    }

    private function doTransform(mixed $value): ?File
    {
        if (null === $value) {
            return null;
        }

        if ($value instanceof File) {
            return $value;
        }

        if (!\is_string($value)) {
            throw new TransformationFailedException('Expected a string or null.');
        }

        $value = mb_substr($value, mb_strpos($value, $this->uploadDir) + mb_strlen($this->uploadDir), mb_strlen($value));

        if (!is_dir(dirname("/tmp/$value"))) {
            mkdir(dirname("/tmp/$value"), 0755, true);
        }
        $file = fopen("/tmp/$value", 'w');

        if (false === $file) {
            $this->logger->error('Unable to open file ' . $value);
            throw new \Exception('Unable to open file ' . $value);
        }

        fwrite($file, 'placeholder');
        fclose($file);

        return new File("/tmp/$value");

        // This code below loads images from S3, and this may be unnecessary, since we can just return a placeholder, not the binary of the file, which is not required for the file upload field or easy admin to work.
        // This was done due to optimisation, since images are loaded synchronously, and not started to be loaded at the same time simutaneously, this can result in ~2s of load time for 5/10 files, each file downloads ~50ms-150ms.

        // $remotePath = $this->uploadDir . $value;
        // try {
        //     $file = fopen("/tmp/$value", 'w');
        //     if (false === $file) {
        //         throw new \Exception('Unable to open file ' . $value);
        //     }
        //     $resource = $this->uploader->download($remotePath);

        //     while (!feof($resource) && false !== ($buffer = fread($resource, 8192))) {
        //         fwrite($file, $buffer);
        //     }

        //     fclose($resource);
        //     fclose($file);

        //     return new File("/tmp/$value");
        // } catch (\Exception $exception) {
        //     $this->logger->error('Unable to retrieve file ' . $remotePath . ' with exception [' . $exception->getMessage() . ']');
        // }

        // return null;
    }

    private function doReverseTransform(mixed $value): ?string
    {
        if (null === $value) {
            return null;
        }

        if ($value instanceof UploadedFile) {
            if (!$value->isValid()) {
                throw new TransformationFailedException($value->getErrorMessage());
            }

            $filename = ($this->uploadFilename)($value);

            return $this->uploader->getPath($this->uploadDir . ($this->uploadValidate)($filename, $this->uploadDir));
        }

        if ($value instanceof File) {
            return $this->uploader->getPath($this->uploadDir . $value->getFilename());
        }

        throw new TransformationFailedException('Expected an instance of File or null.');
    }
}

You need also to create abstract controller

<?php

declare(strict_types=1);

namespace App\UI\Admin\Controller;

use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Model\FileUploadState;
use Symfony\Component\Form\FormInterface;

use function Symfony\Component\String\u;

/**
 * @template T of object
 *
 * @extends \EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController<T>
 */
abstract class AbstractCrudController extends \EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController
{
    /**
     * @param FormInterface<FileUploadState> $form
     */
    public function processUploadedFiles(FormInterface $form): void
    {
        foreach ($form as $child) {
            $config = $child->getConfig();
            $type   = $config->getType()->getInnerType();
            $c      = get_class($type);
            if (
                !(
                    $type instanceof \EasyCorp\Bundle\EasyAdminBundle\Form\Type\FileUploadType
                    || $type instanceof \App\UI\Admin\Form\Type\FileUploadType
                )
            ) {
                if ($config->getCompound()) {
                    $this->processUploadedFiles($child);
                }

                continue;
            }

            /** @var FileUploadState $state */
            $state = $config->getAttribute('state');

            if (!$state->isModified()) {
                continue;
            }

            $uploadDelete = $config->getOption('upload_delete');
            $uploadDir    = $config->getOption('upload_dir');

            if (
                $state->hasCurrentFiles()
                && ($state->isDelete()
                    || (!$state->isAddAllowed() && $state->hasUploadedFiles()))
            ) {
                foreach ($state->getCurrentFiles() as $file) {
                    $uploadDelete($file, $uploadDir);
                }
                $state->setCurrentFiles([]);
            }

            $filePaths = (array) $child->getData();
            $uploadNew = $config->getOption('upload_new');

            foreach ($state->getUploadedFiles() as $index => $file) {
                $fileName = u($filePaths[$index]);
                $fileName = $fileName
                    ->slice(
                        $fileName->indexOf($uploadDir) + mb_strlen($uploadDir),
                        $fileName->length()
                    )
                    ->toString();
                $uploadNew($file, $uploadDir, $fileName);
            }
        }
    }
}

Usage:

FileField::new("issueDescription.logoPath")->setUploadDir(
    "public/files/issue/issue-description/logo/",
),

tskorupka avatar Oct 25 '25 15:10 tskorupka