diff --git a/composer.json b/composer.json index df27b4e008..62c8d46c16 100644 --- a/composer.json +++ b/composer.json @@ -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", @@ -73,5 +74,8 @@ "branch-alias": { "dev-master": "4.0.x-dev" } + }, + "suggest": { + "league/flysystem-bundle": "Allows to manage uploaded file destination" } } diff --git a/src/Controller/AbstractCrudController.php b/src/Controller/AbstractCrudController.php index b4ea26b6b1..228373dbc9 100644 --- a/src/Controller/AbstractCrudController.php +++ b/src/Controller/AbstractCrudController.php @@ -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([]); } @@ -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); } } } diff --git a/src/Decorator/FlysystemFile.php b/src/Decorator/FlysystemFile.php new file mode 100644 index 0000000000..7d80b37f8d --- /dev/null +++ b/src/Decorator/FlysystemFile.php @@ -0,0 +1,28 @@ +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()); + } +} \ No newline at end of file diff --git a/src/Field/Configurator/ImageConfigurator.php b/src/Field/Configurator/ImageConfigurator.php index 6eadd25a8b..9883ea71ec 100644 --- a/src/Field/Configurator/ImageConfigurator.php +++ b/src/Field/Configurator/ImageConfigurator.php @@ -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; /** @@ -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)); @@ -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; diff --git a/src/Field/ImageField.php b/src/Field/ImageField.php index 1ca2fe85ad..74db2084f5 100644 --- a/src/Field/ImageField.php +++ b/src/Field/ImageField.php @@ -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; /** @@ -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 @@ -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 @@ -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; + } } diff --git a/src/Form/DataTransformer/StringToFileTransformer.php b/src/Form/DataTransformer/StringToFileTransformer.php index 740d718aef..b678041be5 100644 --- a/src/Form/DataTransformer/StringToFileTransformer.php +++ b/src/Form/DataTransformer/StringToFileTransformer.php @@ -2,9 +2,11 @@ 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; /** @@ -12,17 +14,19 @@ */ 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 @@ -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); } diff --git a/src/Form/Type/FileUploadType.php b/src/Form/Type/FileUploadType.php index ba736308f5..294f2548de 100644 --- a/src/Form/Type/FileUploadType.php +++ b/src/Form/Type/FileUploadType.php @@ -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; @@ -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 @@ -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(); @@ -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, @@ -123,9 +139,10 @@ 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']); @@ -133,8 +150,13 @@ public function configureOptions(OptionsResolver $resolver): void $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; } diff --git a/tests/Field/ImageFieldTest.php b/tests/Field/ImageFieldTest.php new file mode 100644 index 0000000000..5e62b9e4d3 --- /dev/null +++ b/tests/Field/ImageFieldTest.php @@ -0,0 +1,28 @@ +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)); + } +} diff --git a/tests/Form/DataTransformer/StringToFileTransformerTest.php b/tests/Form/DataTransformer/StringToFileTransformerTest.php new file mode 100644 index 0000000000..0ba5886e57 --- /dev/null +++ b/tests/Form/DataTransformer/StringToFileTransformerTest.php @@ -0,0 +1,29 @@ + '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); + } +}