diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 21776a092f..c91e24da4c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -89,7 +89,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 diff --git a/composer.json b/composer.json index 058c7b97a8..5e4fb534ad 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ }, "require-dev": { "doctrine/doctrine-fixtures-bundle": "^3.4|3.5.x-dev", + "league/flysystem-bundle": "^3.4", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.0", "phpstan/phpstan-phpunit": "^2.0", @@ -59,6 +60,9 @@ "symfony/process": "^5.4|^6.0|^7.0", "symfony/web-link": "^5.4|^6.0|^7.0" }, + "suggest": { + "league/flysystem-bundle": "Allows to manage uploaded file destination" + }, "config": { "sort-packages": true, "allow-plugins": { diff --git a/config/services.php b/config/services.php index 63358166bf..ccea918337 100644 --- a/config/services.php +++ b/config/services.php @@ -2,6 +2,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use EasyCorp\Bundle\EasyAdminBundle\Adapter\UploadedFileAdapterFactory; use EasyCorp\Bundle\EasyAdminBundle\ArgumentResolver\AdminContextResolver; use EasyCorp\Bundle\EasyAdminBundle\ArgumentResolver\BatchActionDtoResolver; use EasyCorp\Bundle\EasyAdminBundle\Asset\AssetPackage; @@ -417,5 +418,8 @@ ->set(Alert::class) ->tag('twig.component') + + ->set(UploadedFileAdapterFactory::class) + ->arg(0, param('kernel.project_dir')) ; }; diff --git a/doc/fields/ImageField.rst b/doc/fields/ImageField.rst index 3eb7181e5a..a75d12f9cf 100644 --- a/doc/fields/ImageField.rst +++ b/doc/fields/ImageField.rst @@ -27,7 +27,26 @@ Basic Information Options ------- -setBasePath +setUploadedFileAdapter +~~~~~~~~~~~~~~~~~~~~~~ + +You need to use this method to define how you want to store and read your files. +EasyAdmin provides two solutions: a classic local storage option using the ``LocalFileAdapter``, +or a solution compatible with S3 storage using the ``FlysystemFileAdapter``. +``FlysystemFileAdapter`` use the ``thephpleague/flysystem-bundle`` dependency to store the files. + +To easily configure the content of the ``setUploadedFileAdapter`` method, we recommend using the ``UploadedFileAdapterFactory`` service, which offers two helper methods: ``createLocalFileAdapter`` and ``createFlysystemFileAdapter``:: + + yield ImageField::new('picture') + ->setUploadedFileAdapter($this->uploadedFileAdapterFactory->createLocalFileAdapter('public/uploads/posts', 'uploads/posts')); + + yield ImageField::new('picture') + ->setUploadedFileAdapter($this->uploadedFileAdapterFactory->createFlysystemFileAdapter($this->defaultStorage)); + + +Of course, you can also create your own storage system by implementing ``UploadedFileAdapterInterface``. + +setBasePath (deprecated) ~~~~~~~~~~~ By default, images are loaded in read-only pages (``index`` and ``detail``) "as is", @@ -36,7 +55,7 @@ without changing their path. If you serve your images under some path (e.g. yield ImageField::new('...')->setBasePath('uploads/images/'); -setUploadDir +setUploadDir (deprecated) ~~~~~~~~~~~~ By default, the contents of uploaded images are stored into files inside the diff --git a/src/Adapter/FlysystemFileAdapter.php b/src/Adapter/FlysystemFileAdapter.php new file mode 100644 index 0000000000..02470aaf39 --- /dev/null +++ b/src/Adapter/FlysystemFileAdapter.php @@ -0,0 +1,56 @@ + + */ +class FlysystemFileAdapter implements UploadedFileAdapterInterface +{ + public function __construct(private FilesystemOperator $fs) + { + } + + public function supports(string $value): bool + { + return $this->fs->fileExists($value); + } + + public function create(string $value): File + { + return new FlysystemFile($this->fs, $value); + } + + public function publicUrl(string $value): string + { + if (method_exists($this->fs, 'publicUrl')) { + return $this->fs->publicUrl($value); + } + throw new \RuntimeException('Cannot generate public URL for this storage. Public URL generation was added in 3.6 of Flysystem. Please upgrade your Flysystem version.'); + } + + public function upload(UploadedFile $file, string $fileName): void + { + $stream = fopen($file->getPathname(), 'r'); + if (false === $stream) { + throw new \RuntimeException('Failed to open file stream'); + } + $this->fs->writeStream($fileName, $stream); + fclose($stream); + } + + public function delete(File $file): void + { + $this->fs->delete($file->getPathname()); + } + + public function exists(string $value): bool + { + return $this->fs->fileExists($value); + } +} diff --git a/src/Adapter/LocalFileAdapter.php b/src/Adapter/LocalFileAdapter.php new file mode 100644 index 0000000000..1d0365cdf4 --- /dev/null +++ b/src/Adapter/LocalFileAdapter.php @@ -0,0 +1,66 @@ + + */ +class LocalFileAdapter implements UploadedFileAdapterInterface +{ + public function __construct(private string $uploadDir, private string $basePath, private string $projectDir) + { + $relativeUploadDir = u($this->uploadDir)->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(); + } + + $this->uploadDir = $absoluteUploadDir; + } + + public function supports(string $value): bool + { + return is_file($this->uploadDir.$value); + } + + public function create(string $value): File + { + return new File($this->uploadDir.$value); + } + + public function publicUrl(string $value): string + { + // add the base path only to images that are not absolute URLs (http or https) or protocol-relative URLs (//) + if (0 !== preg_match('/^(http[s]?|\/\/)/i', $value)) { + return $value; + } + + // remove project path from filepath + $value = str_replace($this->projectDir.\DIRECTORY_SEPARATOR.'public'.\DIRECTORY_SEPARATOR, '', $value); + + return \strlen($this->basePath) > 0 + ? rtrim($this->basePath, '/').'/'.ltrim($value, '/') + : '/'.ltrim($value, '/'); + } + + public function upload(UploadedFile $file, string $fileName): void + { + $file->move($this->uploadDir, $fileName); + } + + public function delete(File $file): void + { + unlink($file->getPathname()); + } + + public function exists(string $value): bool + { + return file_exists($this->uploadDir.$value); + } +} diff --git a/src/Adapter/UploadedFileAdapterFactory.php b/src/Adapter/UploadedFileAdapterFactory.php new file mode 100644 index 0000000000..81b6f864c4 --- /dev/null +++ b/src/Adapter/UploadedFileAdapterFactory.php @@ -0,0 +1,28 @@ + + */ +class UploadedFileAdapterFactory +{ + private string $projectDir; + + public function __construct(string $projectDir) + { + $this->projectDir = $projectDir; + } + + public function createLocalFileAdapter(string $uploadDir, string $basePath): LocalFileAdapter + { + return new LocalFileAdapter($uploadDir, $basePath, $this->projectDir); + } + + public function createFlysystemFileAdapter(FilesystemOperator $filesystemOperator): FlysystemFileAdapter + { + return new FlysystemFileAdapter($filesystemOperator); + } +} diff --git a/src/Adapter/UploadedFileAdapterInterface.php b/src/Adapter/UploadedFileAdapterInterface.php new file mode 100644 index 0000000000..28a53db7b0 --- /dev/null +++ b/src/Adapter/UploadedFileAdapterInterface.php @@ -0,0 +1,24 @@ + + */ +interface UploadedFileAdapterInterface +{ + public function supports(string $value): bool; + + public function create(string $value): File; + + public function publicUrl(string $value): string; + + public function upload(UploadedFile $file, string $fileName): void; + + public function delete(File $file): void; + + public function exists(string $value): bool; +} diff --git a/src/Attribute/AdminDashboard.php b/src/Attribute/AdminDashboard.php index eef97709d7..dfdda6a268 100644 --- a/src/Attribute/AdminDashboard.php +++ b/src/Attribute/AdminDashboard.php @@ -12,11 +12,11 @@ public function __construct( /** * @var string|null $routePath The path of the Symfony route that will be created for the dashboard (e.g. '/admin) */ - public /* ?string */ $routePath = null, + /* ?string */ public $routePath = null, /** * @var string|null $routeName The name of the Symfony route that will be created for the dashboard (e.g. 'admin') */ - public /* ?string */ $routeName = null, + /* ?string */ public $routeName = null, /** * @var array{ * requirements?: array, diff --git a/src/Controller/AbstractCrudController.php b/src/Controller/AbstractCrudController.php index df7da1c487..2b8014aa2c 100644 --- a/src/Controller/AbstractCrudController.php +++ b/src/Controller/AbstractCrudController.php @@ -626,22 +626,22 @@ protected function processUploadedFiles(FormInterface $form): void continue; } + $uploadedFileAdapter = $config->getOption('uploaded_file_adapter'); $uploadDelete = $config->getOption('upload_delete'); if ($state->hasCurrentFiles() && ($state->isDelete() || (!$state->isAddAllowed() && $state->hasUploadedFiles()))) { foreach ($state->getCurrentFiles() as $file) { - $uploadDelete($file); + $uploadDelete($file, $uploadedFileAdapter); } $state->setCurrentFiles([]); } $filePaths = (array) $child->getData(); - $uploadDir = $config->getOption('upload_dir'); $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, $fileName, $uploadedFileAdapter); } } } diff --git a/src/Decorator/FlysystemFile.php b/src/Decorator/FlysystemFile.php new file mode 100644 index 0000000000..4dc59ec4fb --- /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()); + } +} diff --git a/src/Field/Configurator/ImageConfigurator.php b/src/Field/Configurator/ImageConfigurator.php index acf27b9a19..6df2672d7b 100644 --- a/src/Field/Configurator/ImageConfigurator.php +++ b/src/Field/Configurator/ImageConfigurator.php @@ -2,13 +2,14 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Field\Configurator; +use EasyCorp\Bundle\EasyAdminBundle\Adapter\LocalFileAdapter; +use EasyCorp\Bundle\EasyAdminBundle\Adapter\UploadedFileAdapterInterface; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; 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; use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField; -use function Symfony\Component\String\u; /** * @author Javier Eguiluz @@ -30,10 +31,19 @@ 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); + $uploadedFileAdapter = $field->getCustomOption(ImageField::OPTION_UPLOADED_FILE_ADAPTER); + + if (null === $uploadedFileAdapter) { + if (null === $field->getCustomOption(ImageField::OPTION_UPLOAD_DIR)) { + throw new \InvalidArgumentException(sprintf('The "%s" image field must define the directory where the images are uploaded using the setUploadDir() method.', $field->getProperty())); + } + $uploadedFileAdapter = new LocalFileAdapter($field->getCustomOption(ImageField::OPTION_UPLOAD_DIR), $configuredBasePath, $this->projectDir); + } + $field->setFormTypeOption('uploaded_file_adapter', $uploadedFileAdapter); $formattedValue = \is_array($field->getValue()) - ? $this->getImagesPaths($field->getValue(), $configuredBasePath) - : $this->getImagePath($field->getValue(), $configuredBasePath); + ? $this->getImagesPaths($field->getValue(), $uploadedFileAdapter) + : $this->getImagePath($field->getValue(), $uploadedFileAdapter); $field->setFormattedValue($formattedValue); $field->setFormTypeOption('upload_filename', $field->getCustomOption(ImageField::OPTION_UPLOADED_FILE_NAME_PATTERN)); @@ -47,19 +57,6 @@ 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())); - } - $relativeUploadDir = u($relativeUploadDir)->trimStart(\DIRECTORY_SEPARATOR)->ensureEnd(\DIRECTORY_SEPARATOR)->toString(); - $isStreamWrapper = filter_var($relativeUploadDir, \FILTER_VALIDATE_URL); - if (false !== $isStreamWrapper) { - $absoluteUploadDir = $relativeUploadDir; - } else { - $absoluteUploadDir = u($relativeUploadDir)->ensureStart($this->projectDir.\DIRECTORY_SEPARATOR)->toString(); - } - $field->setFormTypeOption('upload_dir', $absoluteUploadDir); - $field->setFormTypeOption('file_constraints', $field->getCustomOption(ImageField::OPTION_FILE_CONSTRAINTS)); } @@ -68,28 +65,22 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c * * @return array */ - private function getImagesPaths(?array $images, ?string $basePath): array + private function getImagesPaths(?array $images, UploadedFileAdapterInterface $uploadedFileAdapter): array { $imagesPaths = []; foreach ($images as $image) { - $imagesPaths[] = $this->getImagePath($image, $basePath); + $imagesPaths[] = $this->getImagePath($image, $uploadedFileAdapter); } return $imagesPaths; } - private function getImagePath(?string $imagePath, ?string $basePath): ?string + private function getImagePath(?string $imagePath, UploadedFileAdapterInterface $uploadedFileAdapter): ?string { - // 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; + if (null === $imagePath) { + return null; } - // remove project path from filepath - $imagePath = str_replace($this->projectDir.\DIRECTORY_SEPARATOR.'public'.\DIRECTORY_SEPARATOR, '', $imagePath); - - return isset($basePath) - ? rtrim($basePath, '/').'/'.ltrim($imagePath, '/') - : '/'.ltrim($imagePath, '/'); + return $uploadedFileAdapter->publicUrl($imagePath); } } diff --git a/src/Field/ImageField.php b/src/Field/ImageField.php index daefc2b5fa..f0e24cd502 100644 --- a/src/Field/ImageField.php +++ b/src/Field/ImageField.php @@ -2,6 +2,7 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Field; +use EasyCorp\Bundle\EasyAdminBundle\Adapter\UploadedFileAdapterInterface; use EasyCorp\Bundle\EasyAdminBundle\Config\Asset; use EasyCorp\Bundle\EasyAdminBundle\Config\Option\TextAlign; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface; @@ -17,10 +18,13 @@ final class ImageField implements FieldInterface { use FieldTrait; + /** @deprecated use OPTION_UPLOADED_FILE_ADAPTER instead */ public const OPTION_BASE_PATH = 'basePath'; + /** @deprecated use OPTION_UPLOADED_FILE_ADAPTER instead */ public const OPTION_UPLOAD_DIR = 'uploadDir'; public const OPTION_UPLOADED_FILE_NAME_PATTERN = 'uploadedFileNamePattern'; public const OPTION_FILE_CONSTRAINTS = 'fileConstraints'; + public const OPTION_UPLOADED_FILE_ADAPTER = 'uploadedFileAdapter'; /** * @param TranslatableInterface|string|false|null $label @@ -39,9 +43,13 @@ public static function new(string $propertyName, $label = null): self ->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_ADAPTER, null) ->setCustomOption(self::OPTION_FILE_CONSTRAINTS, [new Image()]); } + /** + * @deprecated use setUploadedFileAdapter instead + */ public function setBasePath(string $path): self { $this->setCustomOption(self::OPTION_BASE_PATH, $path); @@ -49,7 +57,15 @@ public function setBasePath(string $path): self return $this; } + public function setUploadedFileAdapter(UploadedFileAdapterInterface $uploadedFileAdapter): self + { + $this->setCustomOption(self::OPTION_UPLOADED_FILE_ADAPTER, $uploadedFileAdapter); + + return $this; + } + /** + * @deprecated use setUploadedFileAdapter instead * Relative to project's root directory (e.g. use 'public/uploads/' for `/public/uploads/`) * Default upload dir: `/public/uploads/images/`. */ diff --git a/src/Form/DataTransformer/StringToFileTransformer.php b/src/Form/DataTransformer/StringToFileTransformer.php index 0cd8bbdf88..101a60b0e4 100644 --- a/src/Form/DataTransformer/StringToFileTransformer.php +++ b/src/Form/DataTransformer/StringToFileTransformer.php @@ -2,6 +2,7 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Form\DataTransformer; +use EasyCorp\Bundle\EasyAdminBundle\Adapter\UploadedFileAdapterInterface; use Symfony\Component\Form\DataTransformerInterface; use Symfony\Component\Form\Exception\TransformationFailedException; use Symfony\Component\HttpFoundation\File\File; @@ -12,19 +13,19 @@ */ class StringToFileTransformer implements DataTransformerInterface { - private string $uploadDir; /** @var callable */ private $uploadFilename; /** @var callable */ private $uploadValidate; private bool $multiple; + private UploadedFileAdapterInterface $uploadedFileAdapter; - public function __construct(string $uploadDir, callable $uploadFilename, callable $uploadValidate, bool $multiple) + public function __construct(callable $uploadFilename, callable $uploadValidate, bool $multiple, UploadedFileAdapterInterface $uploadedFileAdapter) { - $this->uploadDir = $uploadDir; $this->uploadFilename = $uploadFilename; $this->uploadValidate = $uploadValidate; $this->multiple = $multiple; + $this->uploadedFileAdapter = $uploadedFileAdapter; } public function transform(mixed $value): mixed @@ -75,8 +76,8 @@ private function doTransform(mixed $value): ?File throw new TransformationFailedException('Expected a string or null.'); } - if (is_file($this->uploadDir.$value)) { - return new File($this->uploadDir.$value); + if ($this->uploadedFileAdapter->supports($value)) { + return $this->uploadedFileAdapter->create($value); } return null; @@ -95,7 +96,7 @@ private function doReverseTransform(mixed $value): ?string $filename = ($this->uploadFilename)($value); - return ($this->uploadValidate)($filename); + return ($this->uploadValidate)($filename, $this->uploadedFileAdapter); } if ($value instanceof File) { diff --git a/src/Form/Type/FileUploadType.php b/src/Form/Type/FileUploadType.php index c9f28dbed3..7b550065af 100644 --- a/src/Form/Type/FileUploadType.php +++ b/src/Form/Type/FileUploadType.php @@ -2,6 +2,7 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Form\Type; +use EasyCorp\Bundle\EasyAdminBundle\Adapter\UploadedFileAdapterInterface; use EasyCorp\Bundle\EasyAdminBundle\Form\DataTransformer\StringToFileTransformer; use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Model\FileUploadState; use Symfony\Component\Form\AbstractType; @@ -36,19 +37,19 @@ public function __construct(string $projectDir) public function buildForm(FormBuilderInterface $builder, array $options): void { - $uploadDir = $options['upload_dir']; $uploadFilename = $options['upload_filename']; $uploadValidate = $options['upload_validate']; $allowAdd = $options['allow_add']; + $uploadedFileAdapter = $options['uploaded_file_adapter']; $options['constraints'] = (bool) $options['multiple'] ? new All($options['file_constraints']) : $options['file_constraints']; - 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['file_constraints']); + 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['file_constraints'], $options['uploaded_file_adapter']); $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($uploadFilename, $uploadValidate, $options['multiple'], $uploadedFileAdapter)); } public function buildView(FormView $view, FormInterface $form, array $options): void @@ -79,24 +80,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 = $this->projectDir.'/public/uploads/files/'; + + $uploadNew = static function (UploadedFile $file, string $fileName, UploadedFileAdapterInterface $uploadedFileAdapter) { + $uploadedFileAdapter->upload($file, $fileName); }; - $uploadDelete = static function (File $file) { - unlink($file->getPathname()); + $uploadDelete = static function (File $file, UploadedFileAdapterInterface $uploadedFileAdapter) { + $uploadedFileAdapter->delete($file); }; $uploadFilename = static fn (UploadedFile $file): string => $file->getClientOriginalName(); - $uploadValidate = static function (string $filename): string { - if (!file_exists($filename)) { + $uploadValidate = static function (string $filename, UploadedFileAdapterInterface $uploadedFileAdapter): string { + if (!$uploadedFileAdapter->exists($filename)) { return $filename; } $index = 1; $pathInfo = pathinfo($filename); - while (file_exists($filename = sprintf('%s/%s_%d.%s', $pathInfo['dirname'], $pathInfo['filename'], $index, $pathInfo['extension']))) { + while ($uploadedFileAdapter->exists($filename = sprintf('%s/%s_%d.%s', $pathInfo['dirname'], $pathInfo['filename'], $index, $pathInfo['extension']))) { ++$index; } @@ -112,7 +115,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, @@ -127,9 +130,10 @@ public function configureOptions(OptionsResolver $resolver): void 'error_bubbling' => false, 'allow_file_upload' => true, 'file_constraints' => [], + 'uploaded_file_adapter' => null, ]); - $resolver->setAllowedTypes('upload_dir', 'string'); + $resolver->setAllowedTypes('upload_dir', ['string', 'null']); $resolver->setAllowedTypes('upload_new', 'callable'); $resolver->setAllowedTypes('upload_delete', 'callable'); $resolver->setAllowedTypes('upload_filename', ['string', 'callable']); @@ -138,8 +142,12 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('allow_add', 'bool'); $resolver->setAllowedTypes('allow_delete', 'bool'); $resolver->setAllowedTypes('file_constraints', [Constraint::class, Constraint::class.'[]']); + $resolver->setAllowedTypes('uploaded_file_adapter', [UploadedFileAdapterInterface::class, 'null']); - $resolver->setNormalizer('upload_dir', function (Options $options, string $value): string { + $resolver->setNormalizer('upload_dir', function (Options $options, ?string $value): ?string { + if (null === $value) { + return null; + } if (\DIRECTORY_SEPARATOR !== mb_substr($value, -1)) { $value .= \DIRECTORY_SEPARATOR; } diff --git a/tests/Adapter/FlysystemFileAdapterTest.php b/tests/Adapter/FlysystemFileAdapterTest.php new file mode 100644 index 0000000000..6deb268c5e --- /dev/null +++ b/tests/Adapter/FlysystemFileAdapterTest.php @@ -0,0 +1,37 @@ + + */ +class FlysystemFileAdapterTest extends TestCase +{ + private const PROJECT_DIR = __DIR__.'/../TestApplication'; + + private UploadedFileAdapterFactory $factory; + + protected function setUp(): void + { + $this->factory = new UploadedFileAdapterFactory(self::PROJECT_DIR); + } + + public function testFlysystemFileAdapterCanCreateFile() + { + $filesystemOperator = $this->createMock(FilesystemOperator::class); + $filesystemOperator->method('fileExists')->willReturn(true); + + /** @var FilesystemOperator $filesystemOperator */ + $adapter = $this->factory->createFlysystemFileAdapter($filesystemOperator); + $this->assertInstanceOf(FlysystemFileAdapter::class, $adapter); + + $this->assertTrue($adapter->exists('test.jpg')); + $this->assertInstanceOf(File::class, $adapter->create('test.jpg')); + } +} diff --git a/tests/Adapter/LocalFileAdapterTest.php b/tests/Adapter/LocalFileAdapterTest.php new file mode 100644 index 0000000000..964922ffd5 --- /dev/null +++ b/tests/Adapter/LocalFileAdapterTest.php @@ -0,0 +1,32 @@ + + */ +class LocalFileAdapterTest extends TestCase +{ + private const PROJECT_DIR = __DIR__.'/../TestApplication'; + + private UploadedFileAdapterFactory $factory; + + protected function setUp(): void + { + $this->factory = new UploadedFileAdapterFactory(self::PROJECT_DIR); + } + + public function testLocalFileAdapterCanCreateFile() + { + $adapter = $this->factory->createLocalFileAdapter('public/uploads/images', 'uploads/images'); + $this->assertInstanceOf(LocalFileAdapter::class, $adapter); + $this->assertInstanceOf(File::class, $adapter->create('symfony.png')); + $this->assertTrue($adapter->exists('symfony.png')); + $this->assertStringContainsString('uploads/images/symfony.png', $adapter->publicUrl('symfony.png')); + } +} diff --git a/tests/Adapter/UploadedFileAdapterFactoryTest.php b/tests/Adapter/UploadedFileAdapterFactoryTest.php new file mode 100644 index 0000000000..8985929b21 --- /dev/null +++ b/tests/Adapter/UploadedFileAdapterFactoryTest.php @@ -0,0 +1,38 @@ + + */ +class UploadedFileAdapterFactoryTest extends TestCase +{ + private const PROJECT_DIR = __DIR__.'/../TestApplication'; + + private UploadedFileAdapterFactory $factory; + + protected function setUp(): void + { + $this->factory = new UploadedFileAdapterFactory(self::PROJECT_DIR); + } + + public function testCreateLocalFileAdapter() + { + $adapter = $this->factory->createLocalFileAdapter('/public/uploads/images', '/uploads/images'); + $this->assertInstanceOf(LocalFileAdapter::class, $adapter); + } + + public function testCreateFlysystemFileAdapter() + { + $filesystemOperator = $this->createMock(FilesystemOperator::class); + /** @var FilesystemOperator $filesystemOperator */ + $adapter = $this->factory->createFlysystemFileAdapter($filesystemOperator); + $this->assertInstanceOf(FlysystemFileAdapter::class, $adapter); + } +} diff --git a/tests/Field/ImageFieldTest.php b/tests/Field/ImageFieldTest.php new file mode 100644 index 0000000000..23681824cc --- /dev/null +++ b/tests/Field/ImageFieldTest.php @@ -0,0 +1,30 @@ +configurator = new ImageConfigurator($projectDir); + } + + public function testFlysystemFileAdapter(): void + { + /** @var UploadedFileAdapterInterface $filesystemOperator */ + $filesystemOperator = $this->createStub(FlysystemFileAdapter::class); + + $field = ImageField::new('foo')->setUploadedFileAdapter($filesystemOperator); + $fieldDto = $this->configure($field); + + self::assertNotNull($fieldDto->getCustomOption(ImageField::OPTION_UPLOADED_FILE_ADAPTER)); + } +} diff --git a/tests/Form/DataTransformer/StringToFileTransformerTest.php b/tests/Form/DataTransformer/StringToFileTransformerTest.php new file mode 100644 index 0000000000..4ab8dc4bc8 --- /dev/null +++ b/tests/Form/DataTransformer/StringToFileTransformerTest.php @@ -0,0 +1,30 @@ + 'foo'; + $uploadValidate = static fn ($filename) => 'foo'; + + $uploadedFileAdapterMock = $this->createStub(UploadedFileAdapterInterface::class); + $uploadedFileAdapterMock + ->method('upload') + ->willReturn(true) + ; + + /** @var UploadedFileAdapterInterface $uploadedFileAdapterMock */ + $stringToFileTransformer = new StringToFileTransformer($uploadFilename, $uploadValidate, false, $uploadedFileAdapterMock); + + $transformedFile = $stringToFileTransformer->transform('bar'); + + self::assertInstanceOf(FlysystemFile::class, $transformedFile); + } +} diff --git a/tests/TestApplication/public/uploads/images/symfony.png b/tests/TestApplication/public/uploads/images/symfony.png new file mode 100644 index 0000000000..c0bf754724 Binary files /dev/null and b/tests/TestApplication/public/uploads/images/symfony.png differ