Skip to content

Commit

Permalink
ability to use flysystem to manage uploaded files
Browse files Browse the repository at this point in the history
  • Loading branch information
emmanuel-averty committed Oct 2, 2023
1 parent 6b1722a commit 4959fc9
Show file tree
Hide file tree
Showing 9 changed files with 181 additions and 31 deletions.
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 as 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;
}
}
14 changes: 11 additions & 3 deletions src/Form/DataTransformer/StringToFileTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,31 @@

namespace EasyCorp\Bundle\EasyAdminBundle\Form\DataTransformer;

use League\Flysystem\FilesystemOperator;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\HttpFoundation\File\File;
use EasyCorp\Bundle\EasyAdminBundle\Decorator\FlysystemFile;
use Symfony\Component\HttpFoundation\File\UploadedFile;

/**
* @author Yonel Ceruto <[email protected]>
*/
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
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));
}
}
29 changes: 29 additions & 0 deletions tests/Form/DataTransformer/StringToFileTransformerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Form\DataTransformer;

use EasyCorp\Bundle\EasyAdminBundle\Decorator\FlysystemFile;
use EasyCorp\Bundle\EasyAdminBundle\Form\DataTransformer\StringToFileTransformer;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;

class StringToFileTransformerTest extends TestCase
{

public function testTransform(): void
{
$uploadFilename = static fn($value) => 'foo';
$uploadValidate = static fn($filename) => 'foo';
$filesystemOperatorMock = $this->createStub(FilesystemOperator::class);
$filesystemOperatorMock
->method('fileExists')
->willReturn(true)
;

$stringToFileTransformer = new StringToFileTransformer(null, $uploadFilename, $uploadValidate, false, $filesystemOperatorMock);

$transformedFile = $stringToFileTransformer->transform('bar');

self::assertInstanceOf(FlysystemFile::class, $transformedFile);
}
}

0 comments on commit 4959fc9

Please sign in to comment.