Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ jobs:
with:
php-version: ${{ matrix.php_version }}
coverage: none
extensions: mbstring, intl, pdo, pdo_sqlite, sqlite3
extensions: mbstring, intl, pdo, pdo_sqlite, sqlite3, fileinfo
ini-values: date.timezone=UTC

- name: symfony/flex is required to install the correct symfony version
Expand Down
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4",
"league/flysystem-bundle": "^3.0",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.9",
"phpstan/phpstan-phpunit": "^1.2",
Expand Down Expand Up @@ -73,5 +74,8 @@
"branch-alias": {
"dev-master": "4.0.x-dev"
}
},
"suggest": {
"league/flysystem-bundle": "Allows to manage uploaded file destination"
}
}
7 changes: 4 additions & 3 deletions src/Controller/AbstractCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -599,11 +599,12 @@ protected function processUploadedFiles(FormInterface $form): void
continue;
}

$filesystemOperator = $config->getOption('filesystem_operator');
$uploadDelete = $config->getOption('upload_delete');

if ($state->hasCurrentFiles() && ($state->isDelete() || (!$state->isAddAllowed() && $state->hasUploadedFiles()))) {
foreach ($state->getCurrentFiles() as $file) {
$uploadDelete($file);
$uploadDelete($file, $filesystemOperator);
}
$state->setCurrentFiles([]);
}
Expand All @@ -613,8 +614,8 @@ protected function processUploadedFiles(FormInterface $form): void
$uploadNew = $config->getOption('upload_new');

foreach ($state->getUploadedFiles() as $index => $file) {
$fileName = u($filePaths[$index])->replace($uploadDir, '')->toString();
$uploadNew($file, $uploadDir, $fileName);
$fileName = u($filePaths[$index]);
$uploadNew($file, $uploadDir, $fileName, $filesystemOperator);
}
}
}
Expand Down
28 changes: 28 additions & 0 deletions src/Decorator/FlysystemFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Decorator;

use League\Flysystem\FilesystemOperator;
use Symfony\Component\HttpFoundation\File\File;

class FlysystemFile extends File
{
private FilesystemOperator $filesystemOperator;

public function __construct(FilesystemOperator $filesystemOperator, string $path)
{
$this->filesystemOperator = $filesystemOperator;

parent::__construct($path, false);
}

public function getSize(): int
{
return $this->filesystemOperator->fileSize($this->getPathname());
}

public function getMTime(): int
{
return $this->filesystemOperator->lastModified($this->getPathname());
}
}
42 changes: 27 additions & 15 deletions src/Field/Configurator/ImageConfigurator.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\UnableToGeneratePublicUrl;
use function Symfony\Component\String\u;

/**
Expand All @@ -30,10 +32,11 @@ public function supports(FieldDto $field, EntityDto $entityDto): bool
public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $context): void
{
$configuredBasePath = $field->getCustomOption(ImageField::OPTION_BASE_PATH);
$filesystemOperator = $field->getCustomOption(ImageField::OPTION_FILESYSTEM_OPERATOR);

$formattedValue = \is_array($field->getValue())
? $this->getImagesPaths($field->getValue(), $configuredBasePath)
: $this->getImagePath($field->getValue(), $configuredBasePath);
? $this->getImagesPaths($field->getValue(), $configuredBasePath, $filesystemOperator)
: $this->getImagePath($field->getValue(), $configuredBasePath, $filesystemOperator);
$field->setFormattedValue($formattedValue);

$field->setFormTypeOption('upload_filename', $field->getCustomOption(ImageField::OPTION_UPLOADED_FILE_NAME_PATTERN));
Expand All @@ -47,32 +50,41 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c
return;
}

$relativeUploadDir = $field->getCustomOption(ImageField::OPTION_UPLOAD_DIR);
if (null === $relativeUploadDir) {
throw new \InvalidArgumentException(sprintf('The "%s" image field must define the directory where the images are uploaded using the setUploadDir() method.', $field->getProperty()));
if (null !== $relativeUploadDir = $field->getCustomOption(ImageField::OPTION_UPLOAD_DIR)) {
$relativeUploadDir = u($relativeUploadDir)->trimStart(\DIRECTORY_SEPARATOR)->ensureEnd(\DIRECTORY_SEPARATOR)->toString();
$isStreamWrapper = filter_var($relativeUploadDir, \FILTER_VALIDATE_URL);
if ($isStreamWrapper) {
$absoluteUploadDir = $relativeUploadDir;
} else {
$absoluteUploadDir = u($relativeUploadDir)->ensureStart($this->projectDir.\DIRECTORY_SEPARATOR)->toString();
}
$field->setFormTypeOption('upload_dir', $absoluteUploadDir);
}
$relativeUploadDir = u($relativeUploadDir)->trimStart(\DIRECTORY_SEPARATOR)->ensureEnd(\DIRECTORY_SEPARATOR)->toString();
$isStreamWrapper = filter_var($relativeUploadDir, \FILTER_VALIDATE_URL);
if ($isStreamWrapper) {
$absoluteUploadDir = $relativeUploadDir;
} else {
$absoluteUploadDir = u($relativeUploadDir)->ensureStart($this->projectDir.\DIRECTORY_SEPARATOR)->toString();

if (null !== $filesystemOperator = $field->getCustomOption(ImageField::OPTION_FILESYSTEM_OPERATOR)) {
$field->setFormTypeOption('filesystem_operator', $filesystemOperator);
}
$field->setFormTypeOption('upload_dir', $absoluteUploadDir);
}

private function getImagesPaths(?array $images, ?string $basePath): array
private function getImagesPaths(?array $images, ?string $basePath, ?FilesystemOperator $filesystemOperator): array
{
$imagesPaths = [];
foreach ($images as $image) {
$imagesPaths[] = $this->getImagePath($image, $basePath);
$imagesPaths[] = $this->getImagePath($image, $basePath, $filesystemOperator);
}

return $imagesPaths;
}

private function getImagePath(?string $imagePath, ?string $basePath): ?string
private function getImagePath(?string $imagePath, ?string $basePath, ?FilesystemOperator $filesystemOperator): ?string
{
if (null !== $filesystemOperator && null !== $imagePath) {
try {
return $filesystemOperator->publicUrl($imagePath);
} catch (UnableToGeneratePublicUrl $e) {
// do nothing : try to get image path with logic below
}
}
// add the base path only to images that are not absolute URLs (http or https) or protocol-relative URLs (//)
if (null === $imagePath || 0 !== preg_match('/^(http[s]?|\/\/)/i', $imagePath)) {
return $imagePath;
Expand Down
20 changes: 19 additions & 1 deletion src/Field/ImageField.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\TextAlign;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\FileUploadType;
use League\Flysystem\FilesystemOperator;
use Symfony\Contracts\Translation\TranslatableInterface;

/**
Expand All @@ -18,6 +19,7 @@ final class ImageField implements FieldInterface
public const OPTION_BASE_PATH = 'basePath';
public const OPTION_UPLOAD_DIR = 'uploadDir';
public const OPTION_UPLOADED_FILE_NAME_PATTERN = 'uploadedFileNamePattern';
public const OPTION_FILESYSTEM_OPERATOR = 'filesystemOperator';

/**
* @param TranslatableInterface|string|false|null $label
Expand All @@ -35,7 +37,9 @@ public static function new(string $propertyName, $label = null): self
->setTextAlign(TextAlign::CENTER)
->setCustomOption(self::OPTION_BASE_PATH, null)
->setCustomOption(self::OPTION_UPLOAD_DIR, null)
->setCustomOption(self::OPTION_UPLOADED_FILE_NAME_PATTERN, '[name].[extension]');
->setCustomOption(self::OPTION_UPLOADED_FILE_NAME_PATTERN, '[name].[extension]')
->setCustomOption(self::OPTION_FILESYSTEM_OPERATOR, null)
;
}

public function setBasePath(string $path): self
Expand Down Expand Up @@ -76,4 +80,18 @@ public function setUploadedFileNamePattern($patternOrCallable): self

return $this;
}

/**
* File system to use in order to :
* - move uploaded file to its final destination
* - delete the previously uploaded file
* - retrieve file public url
* See https://github.com/thephpleague/flysystem-bundle.
*/
public function setFilesystemOperator(FilesystemOperator $filesystemOperator): self
{
$this->setCustomOption(self::OPTION_FILESYSTEM_OPERATOR, $filesystemOperator);

return $this;
}
}
18 changes: 13 additions & 5 deletions src/Form/DataTransformer/StringToFileTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace EasyCorp\Bundle\EasyAdminBundle\Form\DataTransformer;

use EasyCorp\Bundle\EasyAdminBundle\Decorator\FlysystemFile;
use League\Flysystem\FilesystemOperator;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\HttpFoundation\File\File;
Expand All @@ -12,20 +14,22 @@
*/
class StringToFileTransformer implements DataTransformerInterface
{
private string $uploadDir;
private ?string $uploadDir;
private $uploadFilename;
private $uploadValidate;
private bool $multiple;
private ?FilesystemOperator $filesystemOperator;

public function __construct(string $uploadDir, callable $uploadFilename, callable $uploadValidate, bool $multiple)
public function __construct(?string $uploadDir, callable $uploadFilename, callable $uploadValidate, bool $multiple, ?FilesystemOperator $filesystemOperator = null)
{
$this->uploadDir = $uploadDir;
$this->uploadFilename = $uploadFilename;
$this->uploadValidate = $uploadValidate;
$this->multiple = $multiple;
$this->filesystemOperator = $filesystemOperator;
}

public function transform($value): mixed
public function transform($value): null|File|array
{
if (null === $value || [] === $value) {
return null;
Expand All @@ -42,7 +46,7 @@ public function transform($value): mixed
return array_map([$this, 'doTransform'], $value);
}

public function reverseTransform($value): mixed
public function reverseTransform($value): null|string|array
{
if (null === $value || [] === $value) {
return null;
Expand Down Expand Up @@ -73,7 +77,11 @@ private function doTransform($value): ?File
throw new TransformationFailedException('Expected a string or null.');
}

if (is_file($this->uploadDir.$value)) {
if (null !== $this->filesystemOperator) {
if ($this->filesystemOperator->fileExists($value)) {
return new FlysystemFile($this->filesystemOperator, $value);
}
} elseif (is_file($this->uploadDir.$value)) {
return new File($this->uploadDir.$value);
}

Expand Down
40 changes: 31 additions & 9 deletions src/Form/Type/FileUploadType.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use EasyCorp\Bundle\EasyAdminBundle\Form\DataTransformer\StringToFileTransformer;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Model\FileUploadState;
use League\Flysystem\FilesystemOperator;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
Expand Down Expand Up @@ -38,14 +39,15 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
$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']);
$filesystemOperator = $options['filesystem_operator'];
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'], $options['filesystem_operator']);

$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($uploadDir, $uploadFilename, $uploadValidate, $options['multiple']));
$builder->addModelTransformer(new StringToFileTransformer($uploadDir, $uploadFilename, $uploadValidate, $options['multiple'], $filesystemOperator));
}

public function buildView(FormView $view, FormInterface $form, array $options): void
Expand Down Expand Up @@ -76,12 +78,26 @@ public function buildView(FormView $view, FormInterface $form, array $options):

public function configureOptions(OptionsResolver $resolver): void
{
$uploadNew = static function (UploadedFile $file, string $uploadDir, string $fileName) {
$file->move($uploadDir, $fileName);
$uploadDir = fn (Options $options) => $options['filesystem_operator'] ? null : $this->projectDir.'/public/uploads/files/';

$uploadNew = static function (UploadedFile $file, ?string $uploadDir, string $fileName, ?FilesystemOperator $filesystemOperator = null) {
if (null === $filesystemOperator) {
$file->move($uploadDir, $fileName);
} else {
if (false === $fh = fopen($file->getPathname(), 'rb')) {
throw new InvalidArgumentException(sprintf('Unable to open file %s for reading', $file->getPathname()));
}
$filesystemOperator->writeStream($uploadDir.'/'.$fileName, $fh);
fclose($fh);
}
};

$uploadDelete = static function (File $file) {
unlink($file->getPathname());
$uploadDelete = static function (File $file, ?FilesystemOperator $filesystemOperator = null) {
if (null === $filesystemOperator) {
unlink($file->getPathname());
} else {
$filesystemOperator->delete($file->getPathname());
}
};

$uploadFilename = static fn (UploadedFile $file): string => $file->getClientOriginalName();
Expand Down Expand Up @@ -109,7 +125,7 @@ public function configureOptions(OptionsResolver $resolver): void
$emptyData = static fn (Options $options) => $options['multiple'] ? [] : null;

$resolver->setDefaults([
'upload_dir' => $this->projectDir.'/public/uploads/files/',
'upload_dir' => $uploadDir,
'upload_new' => $uploadNew,
'upload_delete' => $uploadDelete,
'upload_filename' => $uploadFilename,
Expand All @@ -123,18 +139,24 @@ public function configureOptions(OptionsResolver $resolver): void
'required' => false,
'error_bubbling' => false,
'allow_file_upload' => true,
'filesystem_operator' => null,
]);

$resolver->setAllowedTypes('upload_dir', 'string');
$resolver->setAllowedTypes('upload_dir', ['null', '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('filesystem_operator', ['null', FilesystemOperator::class]);

$resolver->setNormalizer('upload_dir', function (Options $options, ?string $value): ?string {
if (null === $value) {
return null;
}

$resolver->setNormalizer('upload_dir', function (Options $options, string $value): string {
if (\DIRECTORY_SEPARATOR !== mb_substr($value, -1)) {
$value .= \DIRECTORY_SEPARATOR;
}
Expand Down
28 changes: 28 additions & 0 deletions tests/Field/ImageFieldTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Field;

use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\ImageConfigurator;
use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField;
use League\Flysystem\FilesystemOperator;

class ImageFieldTest extends AbstractFieldTest
{
protected function setUp(): void
{
parent::setUp();

$projectDir = __DIR__.'/../TestApplication';
$this->configurator = new ImageConfigurator($projectDir);
}

public function testFilesystemOperator(): void
{
$filesystemOperator = $this->createStub(FilesystemOperator::class);

$field = ImageField::new('foo')->setFilesystemOperator($filesystemOperator);
$fieldDto = $this->configure($field);

self::assertNotNull($fieldDto->getCustomOption(ImageField::OPTION_FILESYSTEM_OPERATOR));
}
}
Loading