Skip to content

Commit 4959fc9

Browse files
ability to use flysystem to manage uploaded files
1 parent 6b1722a commit 4959fc9

File tree

9 files changed

+181
-31
lines changed

9 files changed

+181
-31
lines changed

composer.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
},
4040
"require-dev": {
4141
"doctrine/doctrine-fixtures-bundle": "^3.4",
42+
"league/flysystem-bundle": "^3.0",
4243
"phpstan/extension-installer": "^1.2",
4344
"phpstan/phpstan": "^1.9",
4445
"phpstan/phpstan-phpunit": "^1.2",
@@ -73,5 +74,8 @@
7374
"branch-alias": {
7475
"dev-master": "4.0.x-dev"
7576
}
77+
},
78+
"suggest": {
79+
"league/flysystem-bundle": "Allows to manage uploaded file destination"
7680
}
7781
}

src/Controller/AbstractCrudController.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -599,11 +599,12 @@ protected function processUploadedFiles(FormInterface $form): void
599599
continue;
600600
}
601601

602+
$filesystemOperator = $config->getOption('filesystem_operator');
602603
$uploadDelete = $config->getOption('upload_delete');
603604

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

615616
foreach ($state->getUploadedFiles() as $index => $file) {
616-
$fileName = u($filePaths[$index])->replace($uploadDir, '')->toString();
617-
$uploadNew($file, $uploadDir, $fileName);
617+
$fileName = u($filePaths[$index]);
618+
$uploadNew($file, $uploadDir, $fileName, $filesystemOperator);
618619
}
619620
}
620621
}

src/Decorator/FlysystemFile.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Decorator;
4+
5+
use League\Flysystem\FilesystemOperator;
6+
use Symfony\Component\HttpFoundation\File\File as File;
7+
8+
class FlysystemFile extends File
9+
{
10+
private FilesystemOperator $filesystemOperator;
11+
12+
public function __construct(FilesystemOperator $filesystemOperator, string $path)
13+
{
14+
$this->filesystemOperator = $filesystemOperator;
15+
16+
parent::__construct($path, false);
17+
}
18+
19+
public function getSize(): int
20+
{
21+
return $this->filesystemOperator->fileSize($this->getPathname());
22+
}
23+
24+
public function getMTime(): int
25+
{
26+
return $this->filesystemOperator->lastModified($this->getPathname());
27+
}
28+
}

src/Field/Configurator/ImageConfigurator.php

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
99
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
1010
use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField;
11+
use League\Flysystem\FilesystemOperator;
12+
use League\Flysystem\UnableToGeneratePublicUrl;
1113
use function Symfony\Component\String\u;
1214

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

3437
$formattedValue = \is_array($field->getValue())
35-
? $this->getImagesPaths($field->getValue(), $configuredBasePath)
36-
: $this->getImagePath($field->getValue(), $configuredBasePath);
38+
? $this->getImagesPaths($field->getValue(), $configuredBasePath, $filesystemOperator)
39+
: $this->getImagePath($field->getValue(), $configuredBasePath, $filesystemOperator);
3740
$field->setFormattedValue($formattedValue);
3841

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

50-
$relativeUploadDir = $field->getCustomOption(ImageField::OPTION_UPLOAD_DIR);
51-
if (null === $relativeUploadDir) {
52-
throw new \InvalidArgumentException(sprintf('The "%s" image field must define the directory where the images are uploaded using the setUploadDir() method.', $field->getProperty()));
53+
if (null !== $relativeUploadDir = $field->getCustomOption(ImageField::OPTION_UPLOAD_DIR)) {
54+
$relativeUploadDir = u($relativeUploadDir)->trimStart(\DIRECTORY_SEPARATOR)->ensureEnd(\DIRECTORY_SEPARATOR)->toString();
55+
$isStreamWrapper = filter_var($relativeUploadDir, \FILTER_VALIDATE_URL);
56+
if ($isStreamWrapper) {
57+
$absoluteUploadDir = $relativeUploadDir;
58+
} else {
59+
$absoluteUploadDir = u($relativeUploadDir)->ensureStart($this->projectDir.\DIRECTORY_SEPARATOR)->toString();
60+
}
61+
$field->setFormTypeOption('upload_dir', $absoluteUploadDir);
5362
}
54-
$relativeUploadDir = u($relativeUploadDir)->trimStart(\DIRECTORY_SEPARATOR)->ensureEnd(\DIRECTORY_SEPARATOR)->toString();
55-
$isStreamWrapper = filter_var($relativeUploadDir, \FILTER_VALIDATE_URL);
56-
if ($isStreamWrapper) {
57-
$absoluteUploadDir = $relativeUploadDir;
58-
} else {
59-
$absoluteUploadDir = u($relativeUploadDir)->ensureStart($this->projectDir.\DIRECTORY_SEPARATOR)->toString();
63+
64+
if (null !== $filesystemOperator = $field->getCustomOption(ImageField::OPTION_FILESYSTEM_OPERATOR)) {
65+
$field->setFormTypeOption('filesystem_operator', $filesystemOperator);
6066
}
61-
$field->setFormTypeOption('upload_dir', $absoluteUploadDir);
6267
}
6368

64-
private function getImagesPaths(?array $images, ?string $basePath): array
69+
private function getImagesPaths(?array $images, ?string $basePath, ?FilesystemOperator $filesystemOperator): array
6570
{
6671
$imagesPaths = [];
6772
foreach ($images as $image) {
68-
$imagesPaths[] = $this->getImagePath($image, $basePath);
73+
$imagesPaths[] = $this->getImagePath($image, $basePath, $filesystemOperator);
6974
}
7075

7176
return $imagesPaths;
7277
}
7378

74-
private function getImagePath(?string $imagePath, ?string $basePath): ?string
79+
private function getImagePath(?string $imagePath, ?string $basePath, ?FilesystemOperator $filesystemOperator): ?string
7580
{
81+
if (null !==$filesystemOperator && null !== $imagePath) {
82+
try {
83+
return $filesystemOperator->publicUrl($imagePath);
84+
} catch (UnableToGeneratePublicUrl $e) {
85+
// do nothing : try to get image path with logic below
86+
}
87+
}
7688
// add the base path only to images that are not absolute URLs (http or https) or protocol-relative URLs (//)
7789
if (null === $imagePath || 0 !== preg_match('/^(http[s]?|\/\/)/i', $imagePath)) {
7890
return $imagePath;

src/Field/ImageField.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\TextAlign;
77
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
88
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\FileUploadType;
9+
use League\Flysystem\FilesystemOperator;
910
use Symfony\Contracts\Translation\TranslatableInterface;
1011

1112
/**
@@ -18,6 +19,7 @@ final class ImageField implements FieldInterface
1819
public const OPTION_BASE_PATH = 'basePath';
1920
public const OPTION_UPLOAD_DIR = 'uploadDir';
2021
public const OPTION_UPLOADED_FILE_NAME_PATTERN = 'uploadedFileNamePattern';
22+
public const OPTION_FILESYSTEM_OPERATOR = 'filesystemOperator';
2123

2224
/**
2325
* @param TranslatableInterface|string|false|null $label
@@ -35,7 +37,9 @@ public static function new(string $propertyName, $label = null): self
3537
->setTextAlign(TextAlign::CENTER)
3638
->setCustomOption(self::OPTION_BASE_PATH, null)
3739
->setCustomOption(self::OPTION_UPLOAD_DIR, null)
38-
->setCustomOption(self::OPTION_UPLOADED_FILE_NAME_PATTERN, '[name].[extension]');
40+
->setCustomOption(self::OPTION_UPLOADED_FILE_NAME_PATTERN, '[name].[extension]')
41+
->setCustomOption(self::OPTION_FILESYSTEM_OPERATOR, null)
42+
;
3943
}
4044

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

7781
return $this;
7882
}
83+
84+
/**
85+
* File system to use in order to :
86+
* - move uploaded file to its final destination
87+
* - delete the previously uploaded file
88+
* - retrieve file public url
89+
* See https://github.com/thephpleague/flysystem-bundle
90+
*/
91+
public function setFilesystemOperator(FilesystemOperator $filesystemOperator): self
92+
{
93+
$this->setCustomOption(self::OPTION_FILESYSTEM_OPERATOR, $filesystemOperator);
94+
95+
return $this;
96+
}
7997
}

src/Form/DataTransformer/StringToFileTransformer.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,31 @@
22

33
namespace EasyCorp\Bundle\EasyAdminBundle\Form\DataTransformer;
44

5+
use League\Flysystem\FilesystemOperator;
56
use Symfony\Component\Form\DataTransformerInterface;
67
use Symfony\Component\Form\Exception\TransformationFailedException;
78
use Symfony\Component\HttpFoundation\File\File;
9+
use EasyCorp\Bundle\EasyAdminBundle\Decorator\FlysystemFile;
810
use Symfony\Component\HttpFoundation\File\UploadedFile;
911

1012
/**
1113
* @author Yonel Ceruto <[email protected]>
1214
*/
1315
class StringToFileTransformer implements DataTransformerInterface
1416
{
15-
private string $uploadDir;
17+
private ?string $uploadDir;
1618
private $uploadFilename;
1719
private $uploadValidate;
1820
private bool $multiple;
21+
private ?FilesystemOperator $filesystemOperator;
1922

20-
public function __construct(string $uploadDir, callable $uploadFilename, callable $uploadValidate, bool $multiple)
23+
public function __construct(?string $uploadDir, callable $uploadFilename, callable $uploadValidate, bool $multiple, ?FilesystemOperator $filesystemOperator = null)
2124
{
2225
$this->uploadDir = $uploadDir;
2326
$this->uploadFilename = $uploadFilename;
2427
$this->uploadValidate = $uploadValidate;
2528
$this->multiple = $multiple;
29+
$this->filesystemOperator = $filesystemOperator;
2630
}
2731

2832
public function transform($value): mixed
@@ -73,7 +77,11 @@ private function doTransform($value): ?File
7377
throw new TransformationFailedException('Expected a string or null.');
7478
}
7579

76-
if (is_file($this->uploadDir.$value)) {
80+
if (null !== $this->filesystemOperator) {
81+
if ($this->filesystemOperator->fileExists($value)) {
82+
return new FlysystemFile($this->filesystemOperator, $value);
83+
}
84+
} elseif (is_file($this->uploadDir.$value)) {
7785
return new File($this->uploadDir.$value);
7886
}
7987

src/Form/Type/FileUploadType.php

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use EasyCorp\Bundle\EasyAdminBundle\Form\DataTransformer\StringToFileTransformer;
66
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Model\FileUploadState;
7+
use League\Flysystem\FilesystemOperator;
78
use Symfony\Component\Form\AbstractType;
89
use Symfony\Component\Form\DataMapperInterface;
910
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -38,14 +39,15 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
3839
$uploadFilename = $options['upload_filename'];
3940
$uploadValidate = $options['upload_validate'];
4041
$allowAdd = $options['allow_add'];
41-
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']);
42+
$filesystemOperator = $options['filesystem_operator'];
43+
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']);
4244

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

4648
$builder->setDataMapper($this);
4749
$builder->setAttribute('state', new FileUploadState($allowAdd));
48-
$builder->addModelTransformer(new StringToFileTransformer($uploadDir, $uploadFilename, $uploadValidate, $options['multiple']));
50+
$builder->addModelTransformer(new StringToFileTransformer($uploadDir, $uploadFilename, $uploadValidate, $options['multiple'], $filesystemOperator));
4951
}
5052

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

7779
public function configureOptions(OptionsResolver $resolver): void
7880
{
79-
$uploadNew = static function (UploadedFile $file, string $uploadDir, string $fileName) {
80-
$file->move($uploadDir, $fileName);
81+
$uploadDir = fn (Options $options) => $options['filesystem_operator'] ? null : $this->projectDir.'/public/uploads/files/';
82+
83+
$uploadNew = static function (UploadedFile $file, ?string $uploadDir, string $fileName, ?FilesystemOperator $filesystemOperator = null) {
84+
if (null === $filesystemOperator) {
85+
$file->move($uploadDir, $fileName);
86+
} else {
87+
if (false === $fh = fopen($file->getPathname(), 'rb')) {
88+
throw new InvalidArgumentException(sprintf('Unable to open file %s for reading', $file->getPathname()));
89+
}
90+
$filesystemOperator->writeStream($uploadDir.'/'.$fileName, $fh);
91+
fclose($fh);
92+
}
8193
};
8294

83-
$uploadDelete = static function (File $file) {
84-
unlink($file->getPathname());
95+
$uploadDelete = static function (File $file, ?FilesystemOperator $filesystemOperator = null) {
96+
if (null === $filesystemOperator) {
97+
unlink($file->getPathname());
98+
} else {
99+
$filesystemOperator->delete($file->getPathname());
100+
}
85101
};
86102

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

111127
$resolver->setDefaults([
112-
'upload_dir' => $this->projectDir.'/public/uploads/files/',
128+
'upload_dir' => $uploadDir,
113129
'upload_new' => $uploadNew,
114130
'upload_delete' => $uploadDelete,
115131
'upload_filename' => $uploadFilename,
@@ -123,18 +139,24 @@ public function configureOptions(OptionsResolver $resolver): void
123139
'required' => false,
124140
'error_bubbling' => false,
125141
'allow_file_upload' => true,
142+
'filesystem_operator' => null,
126143
]);
127144

128-
$resolver->setAllowedTypes('upload_dir', 'string');
145+
$resolver->setAllowedTypes('upload_dir', ['null', 'string']);
129146
$resolver->setAllowedTypes('upload_new', 'callable');
130147
$resolver->setAllowedTypes('upload_delete', 'callable');
131148
$resolver->setAllowedTypes('upload_filename', ['string', 'callable']);
132149
$resolver->setAllowedTypes('upload_validate', 'callable');
133150
$resolver->setAllowedTypes('download_path', ['null', 'string']);
134151
$resolver->setAllowedTypes('allow_add', 'bool');
135152
$resolver->setAllowedTypes('allow_delete', 'bool');
153+
$resolver->setAllowedTypes('filesystem_operator', ['null', FilesystemOperator::class]);
154+
155+
$resolver->setNormalizer('upload_dir', function (Options $options, ?string $value): ?string {
156+
if (null === $value) {
157+
return null;
158+
}
136159

137-
$resolver->setNormalizer('upload_dir', function (Options $options, string $value): string {
138160
if (\DIRECTORY_SEPARATOR !== mb_substr($value, -1)) {
139161
$value .= \DIRECTORY_SEPARATOR;
140162
}

tests/Field/ImageFieldTest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Field;
4+
5+
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\ImageConfigurator;
6+
use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField;
7+
use League\Flysystem\FilesystemOperator;
8+
9+
class ImageFieldTest extends AbstractFieldTest
10+
{
11+
protected function setUp(): void
12+
{
13+
parent::setUp();
14+
15+
$projectDir = __DIR__.'/../TestApplication';
16+
$this->configurator = new ImageConfigurator($projectDir);
17+
}
18+
19+
public function testFilesystemOperator(): void
20+
{
21+
$filesystemOperator = $this->createStub(FilesystemOperator::class);
22+
23+
$field = ImageField::new('foo')->setFilesystemOperator($filesystemOperator);
24+
$fieldDto = $this->configure($field);
25+
26+
self::assertNotNull($fieldDto->getCustomOption(ImageField::OPTION_FILESYSTEM_OPERATOR));
27+
}
28+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Form\DataTransformer;
4+
5+
use EasyCorp\Bundle\EasyAdminBundle\Decorator\FlysystemFile;
6+
use EasyCorp\Bundle\EasyAdminBundle\Form\DataTransformer\StringToFileTransformer;
7+
use League\Flysystem\FilesystemOperator;
8+
use PHPUnit\Framework\TestCase;
9+
10+
class StringToFileTransformerTest extends TestCase
11+
{
12+
13+
public function testTransform(): void
14+
{
15+
$uploadFilename = static fn($value) => 'foo';
16+
$uploadValidate = static fn($filename) => 'foo';
17+
$filesystemOperatorMock = $this->createStub(FilesystemOperator::class);
18+
$filesystemOperatorMock
19+
->method('fileExists')
20+
->willReturn(true)
21+
;
22+
23+
$stringToFileTransformer = new StringToFileTransformer(null, $uploadFilename, $uploadValidate, false, $filesystemOperatorMock);
24+
25+
$transformedFile = $stringToFileTransformer->transform('bar');
26+
27+
self::assertInstanceOf(FlysystemFile::class, $transformedFile);
28+
}
29+
}

0 commit comments

Comments
 (0)