├── tests ├── Functional │ ├── .gitignore │ ├── routes.yml │ ├── Twig │ │ ├── PathResolver.php │ │ ├── path_resolver_config.yml │ │ ├── PathResolverTest.php │ │ └── FilesExtensionTest.php │ ├── Preview │ │ ├── config_no_messenger.yml │ │ ├── config_messenger.yml │ │ ├── config.yml │ │ ├── PreviewGeneratorMessengerTest.php │ │ └── PreviewGeneratorTest.php │ ├── Entity │ │ ├── File.php │ │ ├── EmbeddableFilePersistentPath.php │ │ ├── Preview.php │ │ ├── PersistentPathFile.php │ │ ├── FileWithPreview.php │ │ └── News.php │ ├── Repository │ │ ├── FileRepository.php │ │ ├── PreviewRepository.php │ │ ├── FileWithPreviewRepository.php │ │ └── PersistentPathFileRepository.php │ ├── config_base.yml │ ├── config.yml │ ├── LiipImagine │ │ ├── config.yml │ │ └── FileFilterPathResolverTest.php │ ├── NamingStrategy │ │ ├── PersistentPathStrategyTest.php │ │ ├── PersistPathStrategy │ │ │ └── config.yml │ │ └── AbstractStrategyTest.php │ ├── Kernel.php │ └── AbstractFunctionalTest.php ├── files │ ├── lorem-ipsum.txt │ ├── image1.jpg │ ├── image2.jpg │ ├── lorem-ipsum.pdf │ └── image1_preview.jpg ├── VirtualFile.php ├── StringableFile.php ├── File.php ├── File2.php ├── File3.php ├── MutableFile.php ├── FileRepository.php ├── NamingStrategy │ ├── DateStrategyTest.php │ ├── NamingStrategyTestCase.php │ ├── SplitHashStrategyTest.php │ ├── NullDirectoryStrategyTest.php │ ├── AppendExtensionStrategyTest.php │ ├── DirectoryChunkSplitStrategyTest.php │ ├── UuidV4StrategyTest.php │ ├── UuidV5StrategyTest.php │ ├── AbstractStrategyTest.php │ ├── PersistentPathStrategyTest.php │ └── DirectoryPrefixStrategyTest.php ├── AbstractModelFactoryTest.php ├── PersistentPathFile.php ├── Repository │ └── ORMTest.php ├── Model │ └── DecoratedFileTest.php ├── PathResolver │ ├── AssetsPathResolverTest.php │ ├── CachePathResolverTest.php │ ├── AwsS3PathResolverTest.php │ ├── DelegatingPathResolverTest.php │ ├── AwsPreSignedS3PathResolverTest.php │ ├── AzureBlobStoragePathResolverTest.php │ └── AzureBlobStorageSASParametersTest.php ├── DependencyInjection │ └── StorageFactoryTest.php ├── Validator │ └── Constraint │ │ └── FileTest.php ├── FileDownloaderTest.php ├── Command │ ├── MigrateNamingStrategyCommandTest.php │ └── VerifyConsistencyCommandTest.php ├── Preview │ └── DimensionTest.php ├── MigratorTest.php ├── Utility │ └── DownloadUtilityTest.php └── Form │ └── Type │ └── FileTypeTest.php ├── .idea ├── encodings.xml ├── codeStyles │ └── codeStyleConfig.xml ├── misc.xml ├── vcs.xml ├── symfony2.xml ├── .gitignore ├── phpunit.xml ├── modules.xml ├── deployment.xml ├── php-test-framework.xml ├── runConfigurations │ └── phpunit_xml_dist.xml └── files.iml ├── src ├── Event │ ├── PostMove.php │ ├── PreMove.php │ ├── PostUpdate.php │ ├── PostUpload.php │ ├── PreRemove.php │ ├── PreUpdate.php │ └── AbstractFileEvent.php ├── UnableToResolvePath.php ├── ArxyFilesBundle.php ├── Entity │ ├── File.php │ ├── EmbeddableFile.php │ └── MutableFile.php ├── Model │ ├── PathAwareFile.php │ ├── MutablePathAware.php │ ├── File.php │ ├── MutableFile.php │ ├── AbstractFile.php │ └── DecoratedFile.php ├── Preview │ ├── DimensionInterface.php │ ├── PreviewGeneratorInterface.php │ ├── GeneratePreviewMessage.php │ ├── PreviewableFile.php │ ├── NoPreviewGeneratorFound.php │ ├── GeneratePreviewMessageHandler.php │ ├── Dimension.php │ ├── PreviewGeneratorMessengerListener.php │ ├── PreviewGeneratorListener.php │ ├── PreviewGenerator.php │ └── ImagePreviewGenerator.php ├── MigratorInterface.php ├── PathResolver │ ├── AzureBlobStorageSASParametersFactory.php │ ├── AssetsPathResolver.php │ ├── AwsS3PathResolver.php │ ├── AzureBlobStoragePathResolver.php │ ├── DelegatingPathResolver.php │ ├── CachePathResolver.php │ ├── AwsS3PreSignedPathResolver.php │ └── AzureBlobStorageSASPathResolver.php ├── PathResolver.php ├── Utility │ ├── FileUtility.php │ ├── NamingStrategyUtility.php │ ├── FileDownloader.php │ ├── DownloadableFile.php │ └── DownloadUtility.php ├── Repository.php ├── Twig │ ├── PathResolverExtension.php │ ├── FilesRuntime.php │ ├── PathResolverRuntime.php │ └── FilesExtension.php ├── NamingStrategy.php ├── ModelFactory.php ├── MigrateableStorage.php ├── LiipImagine │ ├── FileFilter.php │ └── FileFilterPathResolver.php ├── Resources │ ├── config │ │ └── doctrine │ │ │ ├── MutableFile.orm.xml │ │ │ ├── File.orm.xml │ │ │ └── EmbeddableFile.orm.xml │ └── views │ │ └── form │ │ └── file.html.twig ├── UnableToUpload.php ├── NamingStrategy │ ├── UuidV4Strategy.php │ ├── UuidV5Strategy.php │ ├── NullDirectoryStrategy.php │ ├── DateStrategy.php │ ├── AppendExtensionStrategy.php │ ├── PersistentPathStrategy.php │ ├── SplitHashStrategy.php │ ├── DirectoryPrefixStrategy.php │ └── DirectoryChunkSplitStrategy.php ├── DependencyInjection │ ├── StorageFactory.php │ └── Configuration.php ├── AbstractModelFactory.php ├── Repository │ └── ORM.php ├── Storage.php ├── Migrator.php ├── EventListener │ ├── PathAwareListener.php │ └── DoctrineORMListener.php ├── ErrorHandler.php ├── FileException.php ├── ManagerInterface.php ├── Form │ ├── EventListener │ │ └── FileUploadListener.php │ └── Type │ │ └── FileType.php ├── Command │ └── MigrateNamingStrategyCommand.php ├── PathResolverManager.php ├── Validator │ └── Constraint │ │ ├── File.php │ │ └── FileValidator.php ├── Storage │ └── FlysystemStorage.php ├── FileMap.php └── DelegatingManager.php ├── .github ├── workflows │ ├── psalm.yml │ ├── codeball.yml │ ├── phpcs.yml │ └── phpunit.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── infection.json.dist ├── psalm.xml.dist ├── .gitignore ├── LICENSE ├── phpunit.xml.dist ├── phpcs.xml └── composer.json /tests/Functional/.gitignore: -------------------------------------------------------------------------------- 1 | var -------------------------------------------------------------------------------- /tests/files/lorem-ipsum.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolar sit amet 2 | -------------------------------------------------------------------------------- /tests/files/image1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Warxcell/files/HEAD/tests/files/image1.jpg -------------------------------------------------------------------------------- /tests/files/image2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Warxcell/files/HEAD/tests/files/image2.jpg -------------------------------------------------------------------------------- /tests/files/lorem-ipsum.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Warxcell/files/HEAD/tests/files/lorem-ipsum.pdf -------------------------------------------------------------------------------- /tests/Functional/routes.yml: -------------------------------------------------------------------------------- 1 | _liip_imagine: 2 | resource: "@LiipImagineBundle/Resources/config/routing.yaml" -------------------------------------------------------------------------------- /tests/files/image1_preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Warxcell/files/HEAD/tests/files/image1_preview.jpg -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/Event/PostMove.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/symfony2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /src/ArxyFilesBundle.php: -------------------------------------------------------------------------------- 1 | getId(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.idea/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/MigratorInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /src/PathResolver.php: -------------------------------------------------------------------------------- 1 | getHash(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/File.php: -------------------------------------------------------------------------------- 1 | id; 14 | } 15 | 16 | public function setId(?int $id): void 17 | { 18 | $this->id = $id; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Preview/PreviewGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Model/File.php: -------------------------------------------------------------------------------- 1 | file = $file; 14 | } 15 | 16 | public function getFile(): PreviewableFile 17 | { 18 | return $this->file; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Repository.php: -------------------------------------------------------------------------------- 1 | id; 16 | } 17 | 18 | public function setId(?int $id): void 19 | { 20 | $this->id = $id; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/File3.php: -------------------------------------------------------------------------------- 1 | id; 16 | } 17 | 18 | public function setId(?int $id): void 19 | { 20 | $this->id = $id; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Twig/PathResolverExtension.php: -------------------------------------------------------------------------------- 1 | id; 16 | } 17 | 18 | public function setId(int $id): void 19 | { 20 | $this->id = $id; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Preview/PreviewableFile.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | interface MigrateableStorage extends Storage 14 | { 15 | /** 16 | * @param T $file 17 | * @throws FileException when file cannot be migrated for some reason 18 | */ 19 | public function migrate(File $file, string $oldPathname, string $newPathname): bool; 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/psalm.yml: -------------------------------------------------------------------------------- 1 | name: Psalm Static analysis 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | psalm: 7 | name: Psalm 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | 13 | - name: Psalm 14 | uses: docker://vimeo/psalm-github-actions 15 | with: 16 | composer_require_dev: true 17 | composer_ignore_platform_reqs: false -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src" 5 | ] 6 | }, 7 | "logs": { 8 | "text": "./infection/infection.log", 9 | "summary": "./infection/summary.log", 10 | "json": "./infection/infection-log.json", 11 | "perMutator": "./infection/per-mutator.md", 12 | "github": true, 13 | "badge": { 14 | "branch": "master" 15 | } 16 | }, 17 | "mutators": { 18 | "@default": true 19 | } 20 | } -------------------------------------------------------------------------------- /tests/Functional/Preview/config_messenger.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - {resource: "config.yml"} 3 | 4 | framework: 5 | messenger: ~ 6 | 7 | services: 8 | _defaults: 9 | public: true 10 | autowire: true # Automatically injects dependencies in your services. 11 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 12 | 13 | Arxy\FilesBundle\Preview\GeneratePreviewMessageHandler: ~ 14 | Arxy\FilesBundle\Preview\PreviewGeneratorMessengerListener: ~ -------------------------------------------------------------------------------- /src/Utility/NamingStrategyUtility.php: -------------------------------------------------------------------------------- 1 | getDirectoryName($file) ?? "") . $namingStrategy->getFileName($file); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.idea/runConfigurations/phpunit_xml_dist.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /psalm.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/Functional/Entity/File.php: -------------------------------------------------------------------------------- 1 | id; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/LiipImagine/FileFilter.php: -------------------------------------------------------------------------------- 1 | filter = $filter; 18 | } 19 | 20 | public function getFilter(): string 21 | { 22 | return $this->filter; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Resources/config/doctrine/MutableFile.orm.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/Preview/NoPreviewGeneratorFound.php: -------------------------------------------------------------------------------- 1 | 5 | {% trans %}Original filename{% endtrans %}: {{ form.vars.data.originalFilename }} 6 | {% trans %}Size{% endtrans %}: {{ form.vars.data.size|format_bytes }} 7 | {% trans %}Mime Type{% endtrans %}: {{ form.vars.data.mimeType }} 8 | {% trans %}Created{% endtrans %}: {{ form.vars.data.createdAt|format_datetime }} 9 | 10 | {% endif %} 11 | 12 | {{ form_widget(form) }} 13 | {% endblock %} -------------------------------------------------------------------------------- /src/Twig/FilesRuntime.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 18 | } 19 | 20 | public function readContent(File $file): string 21 | { 22 | return $this->manager->read($file); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/UnableToUpload.php: -------------------------------------------------------------------------------- 1 | relatedFile = $file; 19 | } 20 | 21 | public function getRelatedFile(): SplFileInfo 22 | { 23 | return $this->relatedFile; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Functional/Repository/FileRepository.php: -------------------------------------------------------------------------------- 1 | pathResolver = $pathResolver; 18 | } 19 | 20 | public function filePath(File $file): string 21 | { 22 | return $this->pathResolver->getPath($file); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Functional/Repository/PreviewRepository.php: -------------------------------------------------------------------------------- 1 | bytes($bytes)->format($precision, ' ') 20 | ), 21 | new TwigFilter('file_content', [FilesRuntime::class, 'readContent']), 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Functional/Repository/FileWithPreviewRepository.php: -------------------------------------------------------------------------------- 1 | namespace = $namespace; 18 | } 19 | 20 | public function getDirectoryName(File $file): ?string 21 | { 22 | return null; 23 | } 24 | 25 | public function getFileName(File $file): string 26 | { 27 | return (string)Uuid::v5($this->namespace, $file->getHash()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.idea/files.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/NamingStrategy/NullDirectoryStrategy.php: -------------------------------------------------------------------------------- 1 | originalStrategy = $originalStrategy; 17 | } 18 | 19 | public function getDirectoryName(File $file): ?string 20 | { 21 | return null; 22 | } 23 | 24 | public function getFileName(File $file): string 25 | { 26 | return $this->originalStrategy->getFileName($file); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Event/AbstractFileEvent.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 19 | $this->file = $file; 20 | } 21 | 22 | public function getManager(): ManagerInterface 23 | { 24 | return $this->manager; 25 | } 26 | 27 | public function getFile(): File 28 | { 29 | return $this->file; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Preview/GeneratePreviewMessageHandler.php: -------------------------------------------------------------------------------- 1 | generator = $generator; 16 | } 17 | 18 | public function __invoke(GeneratePreviewMessage $message): void 19 | { 20 | $file = $message->getFile(); 21 | try { 22 | $file->setPreview($this->generator->generate($file)); 23 | } catch (NoPreviewGeneratorFound $exception) { 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/phpcs.yml: -------------------------------------------------------------------------------- 1 | name: PHP CodeSniffer 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: "Install PHP" 11 | uses: "shivammathur/setup-php@v2" 12 | with: 13 | coverage: "none" 14 | php-version: "8.0" 15 | tools: "cs2pr" 16 | 17 | - name: "Install dependencies with Composer" 18 | uses: "ramsey/composer-install@v1" 19 | 20 | # https://github.com/doctrine/.github/issues/3 21 | - name: "Run PHP_CodeSniffer" 22 | run: "vendor/bin/phpcs -q --no-colors --report=checkstyle | cs2pr" 23 | -------------------------------------------------------------------------------- /src/DependencyInjection/StorageFactory.php: -------------------------------------------------------------------------------- 1 | create(new SplFileInfo(__DIR__ . '/files/image1.jpg'), 'name', 12345, 'hash', 'mimeType'); 17 | 18 | self::assertSame('name', $file->getOriginalFilename()); 19 | self::assertSame(12345, $file->getSize()); 20 | self::assertSame('hash', $file->getHash()); 21 | self::assertSame('mimeType', $file->getMimeType()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Functional/Entity/EmbeddableFilePersistentPath.php: -------------------------------------------------------------------------------- 1 | pathname; 24 | } 25 | 26 | public function setPathname(string $pathname): void 27 | { 28 | $this->pathname = $pathname; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/NamingStrategy/DateStrategy.php: -------------------------------------------------------------------------------- 1 | format = $format; 18 | } 19 | 20 | public function getDirectoryName(File $file): ?string 21 | { 22 | return $file->getCreatedAt()->format($this->format) . DIRECTORY_SEPARATOR; 23 | } 24 | 25 | public function getFileName(File $file): string 26 | { 27 | return $file->getHash(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Functional/config_base.yml: -------------------------------------------------------------------------------- 1 | framework: 2 | test: ~ 3 | secret: ydslvbxkcen473w89r8qkaponbcyd 4 | router: 5 | resource: '%kernel.project_dir%/routes.yml' 6 | utf8: true 7 | 8 | doctrine: 9 | dbal: 10 | driver: pdo_sqlite 11 | memory: true 12 | charset: UTF8 13 | orm: 14 | auto_generate_proxy_classes: "%kernel.debug%" 15 | naming_strategy: doctrine.orm.naming_strategy.underscore 16 | auto_mapping: true 17 | mappings: 18 | ArxyFilesBundleTestsFunctionalEntity: 19 | type: annotation 20 | dir: '%kernel.project_dir%/Entity' 21 | is_bundle: false 22 | prefix: Arxy\FilesBundle\Tests\Functional\Entity 23 | 24 | flysystem: 25 | storages: 26 | in_memory: 27 | adapter: 'memory' -------------------------------------------------------------------------------- /src/AbstractModelFactory.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class AbstractModelFactory implements ModelFactory 15 | { 16 | /** 17 | * @var class-string 18 | */ 19 | private string $class; 20 | 21 | /** 22 | * @param class-string $class 23 | */ 24 | public function __construct(string $class) 25 | { 26 | $this->class = $class; 27 | } 28 | 29 | public function create( 30 | SplFileInfo $file, 31 | string $originalFilename, 32 | int $size, 33 | string $hash, 34 | string $mimeType 35 | ): File { 36 | return new $this->class($originalFilename, $size, $hash, $mimeType); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Repository/ORM.php: -------------------------------------------------------------------------------- 1 | findOneBy( 15 | [ 16 | 'hash' => $hash, 17 | 'size' => $size, 18 | ] 19 | ); 20 | } 21 | 22 | abstract public function findOneBy(array $criteria, array $orderBy = null); 23 | 24 | public function findAllForBatchProcessing(): iterable 25 | { 26 | $query = $this->createQueryBuilder('file')->getQuery(); 27 | 28 | return $query->toIterable(); 29 | } 30 | 31 | /** @return QueryBuilder */ 32 | abstract public function createQueryBuilder($alias, $indexBy = null); 33 | } 34 | -------------------------------------------------------------------------------- /src/Resources/config/doctrine/File.orm.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/PathResolver/AssetsPathResolver.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 21 | $this->packages = $packages; 22 | $this->package = $package; 23 | } 24 | 25 | public function getPath(File $file): string 26 | { 27 | return $this->packages->getUrl($this->manager->getPathname($file), $this->package); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/PathResolver/AwsS3PathResolver.php: -------------------------------------------------------------------------------- 1 | s3Client = $s3Client; 21 | $this->bucket = $bucket; 22 | $this->manager = $manager; 23 | } 24 | 25 | public function getPath(File $file): string 26 | { 27 | return $this->s3Client->getObjectUrl($this->bucket, $this->manager->getPathname($file)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Preview/Dimension.php: -------------------------------------------------------------------------------- 1 | width = $width; 24 | $this->height = $height; 25 | } 26 | 27 | public function getWidth(): int 28 | { 29 | return $this->width; 30 | } 31 | 32 | public function getHeight(): int 33 | { 34 | return $this->height; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Storage.php: -------------------------------------------------------------------------------- 1 | originalStrategy = $originalStrategy; 19 | } 20 | 21 | public function getDirectoryName(File $file): ?string 22 | { 23 | return $this->originalStrategy->getDirectoryName($file); 24 | } 25 | 26 | public function getFileName(File $file): string 27 | { 28 | $extension = pathinfo($file->getOriginalFilename(), PATHINFO_EXTENSION); 29 | 30 | return $this->originalStrategy->getFileName($file) . '.' . $extension; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/NamingStrategy/PersistentPathStrategy.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class PersistentPathStrategy implements NamingStrategy 18 | { 19 | public function getDirectoryName(File $file): ?string 20 | { 21 | $pathname = $file->getPathname(); 22 | 23 | $directory = dirname($pathname); 24 | if ($directory === '.') { 25 | return null; 26 | } 27 | 28 | return $directory . DIRECTORY_SEPARATOR; 29 | } 30 | 31 | public function getFileName(File $file): string 32 | { 33 | $pathname = $file->getPathname(); 34 | 35 | return basename($pathname); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Resources/config/doctrine/EmbeddableFile.orm.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/Functional/Entity/Preview.php: -------------------------------------------------------------------------------- 1 | id; 31 | } 32 | 33 | public function getPathname(): string 34 | { 35 | return $this->pathname; 36 | } 37 | 38 | public function setPathname(string $pathname): void 39 | { 40 | $this->pathname = $pathname; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/NamingStrategy/SplitHashStrategy.php: -------------------------------------------------------------------------------- 1 | splitLength = $splitLength; 24 | } 25 | 26 | public function getDirectoryName(File $file): ?string 27 | { 28 | return chunk_split($file->getHash(), $this->splitLength, DIRECTORY_SEPARATOR); 29 | } 30 | 31 | public function getFileName(File $file): string 32 | { 33 | return $file->getHash(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Functional/Entity/PersistentPathFile.php: -------------------------------------------------------------------------------- 1 | id; 31 | } 32 | 33 | public function getPathname(): string 34 | { 35 | return $this->pathname; 36 | } 37 | 38 | public function setPathname(string $pathname): void 39 | { 40 | $this->pathname = $pathname; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/LiipImagine/FileFilterPathResolver.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class FileFilterPathResolver implements PathResolver 16 | { 17 | private ManagerInterface $fileManager; 18 | private CacheManager $cacheManager; 19 | 20 | public function __construct(ManagerInterface $fileManager, CacheManager $cacheManager) 21 | { 22 | $this->fileManager = $fileManager; 23 | $this->cacheManager = $cacheManager; 24 | } 25 | 26 | public function getPath(File $file): string 27 | { 28 | return $this->cacheManager->getBrowserPath( 29 | $this->fileManager->getPathname($file->getDecorated()), 30 | $file->getFilter() 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/NamingStrategy/NamingStrategyTestCase.php: -------------------------------------------------------------------------------- 1 | file = $file; 18 | $this->expectedDirectoryName = $expectedDirectoryName; 19 | $this->expectedFilename = $expectedFilename; 20 | } 21 | 22 | public function getFile(): File 23 | { 24 | return $this->file; 25 | } 26 | 27 | public function getExpectedDirectoryName(): ?string 28 | { 29 | return $this->expectedDirectoryName; 30 | } 31 | 32 | public function getExpectedFilename(): string 33 | { 34 | return $this->expectedFilename; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Migrator.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 22 | $this->old = $oldNamingStrategy; 23 | $this->new = $newNamingStrategy; 24 | } 25 | 26 | public function migrate(File $file): bool 27 | { 28 | $oldName = NamingStrategyUtility::getPathnameFromStrategy($this->old, $file); 29 | $newName = NamingStrategyUtility::getPathnameFromStrategy($this->new, $file); 30 | 31 | return $this->storage->migrate($file, $oldName, $newName); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/phpunit.yml: -------------------------------------------------------------------------------- 1 | name: PhpUnit 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | include: 11 | - php: '7.4' 12 | - php: '8.0' 13 | env: 14 | extensions: bcmath gd xdebug 15 | XDEBUG_MODE: coverage 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: php-actions/composer@v6 19 | with: 20 | php_version: '${{ matrix.php }}' 21 | php_extensions: "${{ env.extensions }}" 22 | - uses: php-actions/phpunit@v3 23 | with: 24 | php_version: '${{ matrix.php }}' 25 | php_extensions: "${{ env.extensions }}" 26 | - name: Coverage Report 27 | uses: actions/upload-artifact@v2 28 | with: 29 | name: coverage 30 | path: coverage 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Cache and logs (Symfony2) 2 | /app/cache/* 3 | /app/logs/* 4 | !app/cache/.gitkeep 5 | !app/logs/.gitkeep 6 | 7 | # Email spool folder 8 | /app/spool/* 9 | 10 | # Cache, session files and logs (Symfony3) 11 | /var/cache/* 12 | /var/logs/* 13 | /var/sessions/* 14 | !var/cache/.gitkeep 15 | !var/logs/.gitkeep 16 | !var/sessions/.gitkeep 17 | 18 | # Parameters 19 | /app/config/parameters.yml 20 | /app/config/parameters.ini 21 | 22 | # Managed by Composer 23 | /app/bootstrap.php.cache 24 | /var/bootstrap.php.cache 25 | /bin/* 26 | !bin/console 27 | !bin/symfony_requirements 28 | /vendor/ 29 | 30 | # Assets and user uploads 31 | /web/bundles/ 32 | /web/uploads/ 33 | 34 | # PHPUnit 35 | /app/phpunit.xml 36 | /phpunit.xml 37 | 38 | # Build data 39 | /build/ 40 | 41 | # Composer PHAR 42 | /composer.phar 43 | 44 | # Backup entities generated with doctrine:generate:entities command 45 | **/Entity/*~ 46 | 47 | # Embedded web-server pid file 48 | /.web-server-pid 49 | 50 | composer.lock 51 | 52 | .phpunit.result.cache 53 | .php_cs.cache 54 | .phpunit.cache 55 | infection 56 | clover.xml 57 | coverage 58 | -------------------------------------------------------------------------------- /tests/NamingStrategy/SplitHashStrategyTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 30 | $this->expectExceptionMessage('$splitLength parameter must be modulus of 32'); 31 | 32 | new NamingStrategy\SplitHashStrategy(6); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Bozhidar Hristov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/PersistentPathFile.php: -------------------------------------------------------------------------------- 1 | pathname = $pathname; 24 | } 25 | 26 | public function getId(): ?int 27 | { 28 | return $this->id; 29 | } 30 | 31 | public function setId(?int $id): void 32 | { 33 | $this->id = $id; 34 | } 35 | 36 | public function getPathname(): string 37 | { 38 | return $this->pathname; 39 | } 40 | 41 | public function setPathname(string $pathname): void 42 | { 43 | $this->pathname = $pathname; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/EventListener/PathAwareListener.php: -------------------------------------------------------------------------------- 1 | namingStrategy = $namingStrategy; 20 | } 21 | 22 | public static function getSubscribedEvents(): array 23 | { 24 | return [ 25 | PostUpload::class => 'onUpload', 26 | ]; 27 | } 28 | 29 | public function onUpload(PostUpload $event): void 30 | { 31 | $entity = $event->getFile(); 32 | 33 | if ($entity instanceof MutablePathAware) { 34 | $entity->setPathname(NamingStrategyUtility::getPathnameFromStrategy($this->namingStrategy, $entity)); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Functional/Twig/PathResolverTest.php: -------------------------------------------------------------------------------- 1 | manager->upload(new SplFileObject($pathname)); 30 | 31 | /** @var Environment $twig */ 32 | $twig = self::getContainer()->get(Environment::class); 33 | $formatted = $twig->render($twig->createTemplate('{{ file_path(file) }}'), ['file' => $file]); 34 | 35 | self::assertSame(md5_file($pathname), $formatted); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/ErrorHandler.php: -------------------------------------------------------------------------------- 1 | client = $client; 21 | $this->container = $container; 22 | $this->manager = $manager; 23 | } 24 | 25 | public function getPath(File $file): string 26 | { 27 | return $this->client->getBlobUrl($this->getContainer(), $this->getBlob($file)); 28 | } 29 | 30 | public function getContainer(): string 31 | { 32 | return $this->container; 33 | } 34 | 35 | public function getBlob(File $file): string 36 | { 37 | return $this->manager->getPathname($file); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/NamingStrategy/NullDirectoryStrategyTest.php: -------------------------------------------------------------------------------- 1 | originalStrategy = $originalStrategy; 20 | $this->prefix = rtrim($prefix, DIRECTORY_SEPARATOR); 21 | } 22 | 23 | public function getDirectoryName(File $file): ?string 24 | { 25 | $directory = $this->originalStrategy->getDirectoryName($file); 26 | if ($directory === null) { 27 | return $this->prefix . DIRECTORY_SEPARATOR; 28 | } 29 | 30 | return rtrim($this->prefix . DIRECTORY_SEPARATOR . $directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; 31 | } 32 | 33 | public function getFileName(File $file): string 34 | { 35 | return $this->originalStrategy->getFileName($file); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/NamingStrategy/AppendExtensionStrategyTest.php: -------------------------------------------------------------------------------- 1 | setId(12345); 33 | yield new NamingStrategyTestCase($file, '1/2/3/', '123.jpg'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | tests 16 | 17 | 18 | 19 | 21 | 22 | src 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/Functional/config.yml: -------------------------------------------------------------------------------- 1 | arxy_files: 2 | managers: 3 | public: 4 | driver: orm 5 | class: 'Arxy\FilesBundle\Tests\Functional\Entity\File' 6 | storage: 'in_memory' 7 | naming_strategy: 'Arxy\FilesBundle\NamingStrategy\SplitHashStrategy' 8 | repository: 'Arxy\FilesBundle\Tests\Functional\Repository\FileRepository' 9 | embeddable_manager: 10 | driver: orm 11 | class: 'Arxy\FilesBundle\Entity\EmbeddableFile' 12 | storage: 'in_memory' 13 | naming_strategy: 'Arxy\FilesBundle\NamingStrategy\SplitHashStrategy' 14 | 15 | services: 16 | _defaults: 17 | public: true 18 | autowire: true # Automatically injects dependencies in your services. 19 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 20 | 21 | Arxy\FilesBundle\Tests\Functional\Repository\FileRepository: ~ 22 | Arxy\FilesBundle\NamingStrategy\SplitHashStrategy: ~ 23 | 24 | Arxy\FilesBundle\NamingStrategy\NullDirectoryStrategy: 25 | decorates: 'Arxy\FilesBundle\NamingStrategy\SplitHashStrategy' 26 | arguments: 27 | - '@.inner' 28 | -------------------------------------------------------------------------------- /tests/NamingStrategy/DirectoryChunkSplitStrategyTest.php: -------------------------------------------------------------------------------- 1 | , PathResolver> */ 14 | private array $resolvers; 15 | 16 | /** 17 | * @param array, PathResolver> $resolvers 18 | */ 19 | public function __construct(array $resolvers) 20 | { 21 | $this->resolvers = $resolvers; 22 | } 23 | 24 | public function getPath(File $file): string 25 | { 26 | return $this->getResolver($file)->getPath($file); 27 | } 28 | 29 | /** 30 | * @template T of File 31 | * @param T $file 32 | * @return PathResolver 33 | * @throws LogicException if no Resolver is found for $file 34 | */ 35 | private function getResolver(File $file): PathResolver 36 | { 37 | $class = get_class($file); 38 | if (!isset($this->resolvers[$class])) { 39 | throw new LogicException('No resolver for ' . $class); 40 | } 41 | 42 | return $this->resolvers[$class]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/NamingStrategy/DirectoryChunkSplitStrategy.php: -------------------------------------------------------------------------------- 1 | originalStrategy = $originalStrategy; 23 | $this->offset = $offset; 24 | $this->length = $length; 25 | $this->chunkSplit = $chunkSplit; 26 | } 27 | 28 | public function getDirectoryName(File $file): ?string 29 | { 30 | $filename = $this->originalStrategy->getFileName($file); 31 | 32 | return chunk_split(substr($filename, $this->offset, $this->length), $this->chunkSplit, DIRECTORY_SEPARATOR); 33 | } 34 | 35 | public function getFileName(File $file): string 36 | { 37 | return $this->originalStrategy->getFileName($file); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Functional/LiipImagine/config.yml: -------------------------------------------------------------------------------- 1 | arxy_files: 2 | managers: 3 | public: 4 | driver: orm 5 | class: 'Arxy\FilesBundle\Tests\Functional\Entity\File' 6 | storage: 'in_memory' 7 | naming_strategy: 'Arxy\FilesBundle\NamingStrategy\SplitHashStrategy' 8 | 9 | services: 10 | _defaults: 11 | public: true 12 | autowire: true # Automatically injects dependencies in your services. 13 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 14 | 15 | Arxy\FilesBundle\Tests\Functional\Repository\FileRepository: ~ 16 | Arxy\FilesBundle\NamingStrategy\SplitHashStrategy: ~ 17 | Arxy\FilesBundle\LiipImagine\FileFilterPathResolver: ~ 18 | 19 | liip_imagine: 20 | driver: "gd" 21 | loaders: 22 | default: 23 | flysystem: 24 | filesystem_service: 'in_memory' 25 | data_loader: default 26 | resolvers: 27 | default: 28 | web_path: ~ 29 | filter_sets: 30 | thumbnail: 31 | quality: 75 32 | filters: 33 | thumbnail: {size: [120, 90], mode: outbound} 34 | background: {size: [124, 94], position: center, color: '#000000'} 35 | -------------------------------------------------------------------------------- /src/Utility/FileDownloader.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 23 | } 24 | 25 | /** 26 | * @throws \ErrorException 27 | */ 28 | public function downloadAsSplFile(File $file): SplFileInfo 29 | { 30 | $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('file-downloader', true); 31 | 32 | ErrorHandler::wrap(static fn (): bool => mkdir($tempDir)); 33 | 34 | $tmpFile = $tempDir . DIRECTORY_SEPARATOR . $file->getOriginalFilename(); 35 | 36 | $destinationStream = ErrorHandler::wrap(static fn () => fopen($tmpFile, 'w')); 37 | ErrorHandler::wrap(fn () => stream_copy_to_stream($this->manager->readStream($file), $destinationStream)); 38 | 39 | fclose($destinationStream); 40 | 41 | return new SplFileInfo($tmpFile); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Model/AbstractFile.php: -------------------------------------------------------------------------------- 1 | originalFilename = $originalFilename; 20 | $this->size = $size; 21 | $this->hash = $hash; 22 | $this->mimeType = $mimeType; 23 | $this->createdAt = new DateTimeImmutable(); 24 | } 25 | 26 | public function getOriginalFilename(): string 27 | { 28 | return $this->originalFilename; 29 | } 30 | 31 | public function getSize(): int 32 | { 33 | return $this->size; 34 | } 35 | 36 | public function getHash(): string 37 | { 38 | return $this->hash; 39 | } 40 | 41 | public function getCreatedAt(): DateTimeImmutable 42 | { 43 | return $this->createdAt; 44 | } 45 | 46 | public function getMimeType(): string 47 | { 48 | return $this->mimeType; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Model/DecoratedFile.php: -------------------------------------------------------------------------------- 1 | decorated = $decorated; 25 | } 26 | 27 | /** 28 | * @return T 29 | */ 30 | public function getDecorated(): File 31 | { 32 | return $this->decorated; 33 | } 34 | 35 | public function getOriginalFilename(): string 36 | { 37 | return $this->decorated->getOriginalFilename(); 38 | } 39 | 40 | public function getSize(): int 41 | { 42 | return $this->decorated->getSize(); 43 | } 44 | 45 | public function getHash(): string 46 | { 47 | return $this->decorated->getHash(); 48 | } 49 | 50 | public function getCreatedAt(): DateTimeImmutable 51 | { 52 | return $this->decorated->getCreatedAt(); 53 | } 54 | 55 | public function getMimeType(): string 56 | { 57 | return $this->decorated->getMimeType(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Functional/NamingStrategy/PersistentPathStrategyTest.php: -------------------------------------------------------------------------------- 1 | $manager */ 27 | $manager = self::getContainer()->get('embeddable'); 28 | $news = new News(); 29 | 30 | $news->setEmbeddableFilePersistentPath( 31 | $manager->upload(new SplFileObject(__DIR__ . '/../../files/image1.jpg')) 32 | ); 33 | $this->entityManager->persist($news); 34 | $this->entityManager->flush(); 35 | 36 | self::assertTrue($this->doesFileExists($news->getEmbeddableFilePersistentPath())); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/NamingStrategy/UuidV4StrategyTest.php: -------------------------------------------------------------------------------- 1 | getStrategy()->getDirectoryName($this->getFile())); 31 | } 32 | 33 | public function testGetFilename(): void 34 | { 35 | $strategy = $this->getStrategy(); 36 | 37 | $filename1 = $strategy->getFileName($this->getFile()); 38 | self::assertTrue(uuid_is_valid($filename1)); 39 | 40 | $filename2 = $strategy->getFileName($this->getFile()); 41 | self::assertTrue(uuid_is_valid($filename2)); 42 | 43 | self::assertNotSame($filename1, $filename2); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | src 5 | tests 6 | 7 | tests/Functional/var/* 8 | 9 | 10 | error 11 | 12 | 13 | 14 | 15 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Entity/MutableFile.php: -------------------------------------------------------------------------------- 1 | modifiedAt = new DateTimeImmutable(); 17 | } 18 | 19 | public function setOriginalFilename(string $originalFilename): void 20 | { 21 | $this->originalFilename = $originalFilename; 22 | } 23 | 24 | public function setSize(int $size): void 25 | { 26 | $this->size = $size; 27 | } 28 | 29 | public function setHash(string $hash): void 30 | { 31 | $this->hash = $hash; 32 | } 33 | 34 | public function getModifiedAt(): DateTimeImmutable 35 | { 36 | return $this->modifiedAt; 37 | } 38 | 39 | public function setModifiedAt(DateTimeImmutable $modifiedAt): void 40 | { 41 | $this->modifiedAt = $modifiedAt; 42 | } 43 | 44 | public function setMimeType(string $mimeType): void 45 | { 46 | $this->mimeType = $mimeType; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/FileException.php: -------------------------------------------------------------------------------- 1 | relatedFile = $file; 19 | } 20 | 21 | public static function unableToRead(File $file, Throwable $exception): self 22 | { 23 | return new self($file, 'Unable to read file', $exception); 24 | } 25 | 26 | public static function unableToWrite(File $file, Throwable $exception): self 27 | { 28 | return new self($file, 'Unable to write file', $exception); 29 | } 30 | 31 | public static function unableToMove(File $file, Throwable $exception): self 32 | { 33 | return new self($file, 'Unable to move file', $exception); 34 | } 35 | 36 | public static function unableToRemove(File $file, Throwable $exception): self 37 | { 38 | return new self($file, 'Unable to remove file', $exception); 39 | } 40 | 41 | public function getRelatedFile(): File 42 | { 43 | return $this->relatedFile; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Repository/ORMTest.php: -------------------------------------------------------------------------------- 1 | getMockForTrait(ORM::class); 18 | 19 | $mock->expects($this->once()) 20 | ->method('findOneBy') 21 | ->with(['hash' => 'hash', 'size' => 123456]); 22 | 23 | $mock->findByHashAndSize('hash', 123456); 24 | } 25 | 26 | public function testFindAllForBatchProcessing(): void 27 | { 28 | $mock = $this->getMockForTrait(ORM::class); 29 | 30 | $queryMock = $this->createMock(AbstractQuery::class); 31 | $queryMock->expects($this->once())->method('toIterable'); 32 | 33 | $qbMock = $this->createMock(QueryBuilder::class); 34 | $qbMock->expects($this->once())->method('getQuery')->willReturn($queryMock); 35 | 36 | $mock->expects($this->once()) 37 | ->method('createQueryBuilder') 38 | ->willReturn($qbMock); 39 | 40 | $mock->findAllForBatchProcessing(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Functional/NamingStrategy/PersistPathStrategy/config.yml: -------------------------------------------------------------------------------- 1 | arxy_files: 2 | managers: 3 | public: 4 | driver: orm 5 | class: 'Arxy\FilesBundle\Tests\Functional\Entity\PersistentPathFile' 6 | storage: 'in_memory' 7 | naming_strategy: 'Arxy\FilesBundle\NamingStrategy\PersistentPathStrategy' 8 | embeddable: 9 | driver: orm 10 | class: 'Arxy\FilesBundle\Tests\Functional\Entity\EmbeddableFilePersistentPath' 11 | storage: 'in_memory' 12 | naming_strategy: 'Arxy\FilesBundle\NamingStrategy\PersistentPathStrategy' 13 | 14 | services: 15 | _defaults: 16 | public: true 17 | autowire: true # Automatically injects dependencies in your services. 18 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 19 | 20 | Arxy\FilesBundle\Tests\Functional\Repository\FileRepository: ~ 21 | Arxy\FilesBundle\NamingStrategy\PersistentPathStrategy: ~ 22 | Arxy\FilesBundle\NamingStrategy: '@Arxy\FilesBundle\NamingStrategy\PersistentPathStrategy' 23 | 24 | Arxy\FilesBundle\NamingStrategy\SplitHashStrategy: ~ 25 | 26 | Arxy\FilesBundle\EventListener\PathAwareListener: 27 | arguments: 28 | $namingStrategy: '@Arxy\FilesBundle\NamingStrategy\SplitHashStrategy' 29 | -------------------------------------------------------------------------------- /tests/NamingStrategy/UuidV5StrategyTest.php: -------------------------------------------------------------------------------- 1 | bus = $bus; 19 | } 20 | 21 | public static function getSubscribedEvents(): array 22 | { 23 | return [ 24 | PostUpload::class => 'postUpload', 25 | PostUpdate::class => 'postUpdate', 26 | ]; 27 | } 28 | 29 | public function postUpload(PostUpload $event): void 30 | { 31 | $file = $event->getFile(); 32 | 33 | if ($file instanceof PreviewableFile) { 34 | $this->bus->dispatch(new GeneratePreviewMessage($file)); 35 | } 36 | } 37 | 38 | public function postUpdate(PostUpdate $event): void 39 | { 40 | $file = $event->getFile(); 41 | 42 | if ($file instanceof PreviewableFile) { 43 | $this->bus->dispatch(new GeneratePreviewMessage($file)); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/PathResolver/CachePathResolver.php: -------------------------------------------------------------------------------- 1 | pathResolver = $pathResolver; 26 | $this->cache = $cache; 27 | $this->expiresAfter = $expiresAfter; 28 | } 29 | 30 | /** 31 | * @throws InvalidArgumentException 32 | */ 33 | public function getPath(File $file): string 34 | { 35 | $key = $file->getHash(); 36 | $item = $this->cache->getItem($key); 37 | 38 | if (!$item->isHit()) { 39 | $item->expiresAfter($this->expiresAfter); 40 | $item->set($this->pathResolver->getPath($file)); 41 | $this->cache->save($item); 42 | } 43 | 44 | return $item->get(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/PathResolver/AwsS3PreSignedPathResolver.php: -------------------------------------------------------------------------------- 1 | s3Client = $s3Client; 28 | $this->bucket = $bucket; 29 | $this->manager = $manager; 30 | $this->expiry = $expiry; 31 | } 32 | 33 | public function getPath(File $file): string 34 | { 35 | $cmd = $this->s3Client->getCommand( 36 | 'GetObject', 37 | [ 38 | 'Bucket' => $this->bucket, 39 | 'Key' => $this->manager->getPathname($file), 40 | ] 41 | ); 42 | 43 | $now = new DateTimeImmutable(); 44 | $request = $this->s3Client->createPresignedRequest($cmd, $now->add($this->expiry)->getTimestamp()); 45 | 46 | return (string)$request->getUri(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/Model/DecoratedFileTest.php: -------------------------------------------------------------------------------- 1 | setId(98); 23 | $this->createdAt = $file->getCreatedAt(); 24 | $this->decorator = new VirtualFile($file); 25 | } 26 | 27 | public function testGetOriginalFilename(): void 28 | { 29 | self::assertSame('filename', $this->decorator->getOriginalFilename()); 30 | } 31 | 32 | public function testGetSize(): void 33 | { 34 | self::assertSame(1234, $this->decorator->getSize()); 35 | } 36 | 37 | public function testGetHash(): void 38 | { 39 | self::assertSame('hash', $this->decorator->getHash()); 40 | } 41 | 42 | public function testGetMimeType(): void 43 | { 44 | self::assertSame('mimeType', $this->decorator->getMimeType()); 45 | } 46 | 47 | public function testGetCreatedAt(): void 48 | { 49 | self::assertSame($this->createdAt, $this->decorator->getCreatedAt()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Utility/DownloadableFile.php: -------------------------------------------------------------------------------- 1 | name = $name; 27 | $this->forceDownload = $forceDownload; 28 | $this->expireAt = $expireAt; 29 | } 30 | 31 | public function getModifiedAt(): DateTimeImmutable 32 | { 33 | if ($this->decorated instanceof MutableFile) { 34 | return $this->decorated->getModifiedAt(); 35 | } else { 36 | return $this->decorated->getCreatedAt(); 37 | } 38 | } 39 | 40 | public function getName(): ?string 41 | { 42 | return $this->name; 43 | } 44 | 45 | public function isForceDownload(): bool 46 | { 47 | return $this->forceDownload; 48 | } 49 | 50 | public function getExpireAt(): ?DateTimeInterface 51 | { 52 | return $this->expireAt; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Functional/Entity/FileWithPreview.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class FileWithPreview extends BaseFile implements PreviewableFile, MutablePathAware 17 | { 18 | /** 19 | * @ORM\Id() 20 | * @ORM\Column(type="integer") 21 | * @ORM\GeneratedValue() 22 | */ 23 | private ?int $id = null; 24 | 25 | /** 26 | * @ORM\OneToOne(targetEntity=Preview::class, cascade={"PERSIST"}) 27 | */ 28 | private ?Preview $preview = null; 29 | 30 | /** 31 | * @ORM\Column(type="string") 32 | */ 33 | private string $pathname; 34 | 35 | public function getId(): ?int 36 | { 37 | return $this->id; 38 | } 39 | 40 | public function getPreview(): ?\Arxy\FilesBundle\Model\File 41 | { 42 | return $this->preview; 43 | } 44 | 45 | public function setPreview(?\Arxy\FilesBundle\Model\File $file): void 46 | { 47 | $this->preview = $file; 48 | } 49 | 50 | public function getPathname(): string 51 | { 52 | return $this->pathname; 53 | } 54 | 55 | public function setPathname(string $pathname): void 56 | { 57 | $this->pathname = $pathname; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Preview/PreviewGeneratorListener.php: -------------------------------------------------------------------------------- 1 | previewGenerator = $previewGenerator; 18 | } 19 | 20 | public static function getSubscribedEvents(): array 21 | { 22 | return [ 23 | PostUpload::class => 'postUpload', 24 | PostUpdate::class => 'postUpdate', 25 | ]; 26 | } 27 | 28 | public function postUpload(PostUpload $event): void 29 | { 30 | $entity = $event->getFile(); 31 | 32 | if ($entity instanceof PreviewableFile) { 33 | $this->generatePreview($entity); 34 | } 35 | } 36 | 37 | public function postUpdate(PostUpdate $event): void 38 | { 39 | $entity = $event->getFile(); 40 | 41 | if ($entity instanceof PreviewableFile) { 42 | $this->generatePreview($entity); 43 | } 44 | } 45 | 46 | private function generatePreview(PreviewableFile $file): void 47 | { 48 | try { 49 | $file->setPreview($this->previewGenerator->generate($file)); 50 | } catch (NoPreviewGeneratorFound $exception) { 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/NamingStrategy/AbstractStrategyTest.php: -------------------------------------------------------------------------------- 1 | getTestCases() as $testCase) { 21 | yield [$testCase->getFile(), $testCase->getExpectedDirectoryName()]; 22 | } 23 | } 24 | 25 | final public function filenameTestData(): iterable 26 | { 27 | foreach ($this->getTestCases() as $testCase) { 28 | yield [$testCase->getFile(), $testCase->getExpectedFilename()]; 29 | } 30 | } 31 | 32 | /** @dataProvider directoryTestData */ 33 | final public function testDirectoryName(File $file, ?string $expected): void 34 | { 35 | self::assertEquals( 36 | $expected, 37 | $this->getStrategy()->getDirectoryName($file) 38 | ); 39 | } 40 | 41 | /** @dataProvider filenameTestData */ 42 | final public function testFilename(File $file, string $expected): void 43 | { 44 | self::assertEquals( 45 | $expected, 46 | $this->getStrategy()->getFileName($file) 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/PathResolver/AssetsPathResolverTest.php: -------------------------------------------------------------------------------- 1 | manager = $this->createMock(ManagerInterface::class); 27 | 28 | $this->pathResolver = new PathResolver\AssetsPathResolver( 29 | $this->manager, 30 | new Packages( 31 | new PathPackage( 32 | '/media', 33 | new EmptyVersionStrategy() 34 | ) 35 | ) 36 | ); 37 | } 38 | 39 | public function testGetPath(): void 40 | { 41 | $file = new File('original_filename.jpg', 125, '098f6bcd4621d373cade4e832627b4f6', 'image/jpeg'); 42 | $this->manager->expects($this->once())->method('getPathname')->with($file)->willReturn('directory/5'); 43 | self::assertSame('/media/directory/5', $this->pathResolver->getPath($file)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/DependencyInjection/StorageFactoryTest.php: -------------------------------------------------------------------------------- 1 | [$this->createMock(Storage::class), Storage::class]; 20 | yield 'flysystem' => [$this->createMock(FilesystemOperator::class), Storage\FlysystemStorage::class]; 21 | } 22 | 23 | /** 24 | * @dataProvider factoryProvider 25 | * @param class-string $expected 26 | */ 27 | public function testFactory(object $object, string $expected): void 28 | { 29 | self::assertInstanceOf($expected, StorageFactory::factory($object)); 30 | } 31 | 32 | public function factoryExceptionProvider(): iterable 33 | { 34 | yield 'non-supported class' => [new stdClass(), LogicException::class, 'Class stdClass not supported']; 35 | } 36 | 37 | /** 38 | * @dataProvider factoryExceptionProvider 39 | * @param class-string $expected 40 | */ 41 | public function testFactoryException(object $object, string $expected, string $expectedMessage): void 42 | { 43 | self::expectException($expected); 44 | self::expectExceptionMessage($expectedMessage); 45 | StorageFactory::factory($object); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Functional/LiipImagine/FileFilterPathResolverTest.php: -------------------------------------------------------------------------------- 1 | file = $this->manager->upload(new SplFileObject(__DIR__ . '/../../files/image1.jpg')); 22 | $this->entityManager->persist($this->file); 23 | $this->entityManager->flush(); 24 | } 25 | 26 | protected static function getConfig(): string 27 | { 28 | return __DIR__ . '/config.yml'; 29 | } 30 | 31 | protected static function getBundles(): array 32 | { 33 | return [new LiipImagineBundle()]; 34 | } 35 | 36 | public function testFilter(): void 37 | { 38 | $pathResolver = self::getContainer()->get(FileFilterPathResolver::class); 39 | assert($pathResolver instanceof FileFilterPathResolver); 40 | 41 | $path = $pathResolver->getPath(new FileFilter($this->file, 'thumbnail')); 42 | self::assertSame( 43 | 'http://localhost/media/cache/resolve/thumbnail/9aa1c5fc/7c938816/6d7ce7fd/46648dd1/9aa1c5fc7c9388166d7ce7fd46648dd1', 44 | $path 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/PathResolver/CachePathResolverTest.php: -------------------------------------------------------------------------------- 1 | cache = new ArrayAdapter(); 26 | $this->decoratedPathResolver = $this->createMock(PathResolver::class); 27 | $this->pathResolver = new PathResolver\CachePathResolver( 28 | $this->decoratedPathResolver, 29 | $this->cache 30 | ); 31 | } 32 | 33 | public function testGetPath(): void 34 | { 35 | $file = new File('original_filename.jpg', 125, '1234567', 'image/jpeg'); 36 | $file->setId(1); 37 | 38 | $this->decoratedPathResolver->expects($this->once())->method('getPath')->with($file)->willReturn('path'); 39 | 40 | self::assertFalse($this->cache->hasItem('1234567')); 41 | self::assertSame('path', $this->pathResolver->getPath($file)); 42 | self::assertTrue($this->cache->hasItem('1234567')); 43 | self::assertSame('path', $this->pathResolver->getPath($file)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/NamingStrategy/PersistentPathStrategyTest.php: -------------------------------------------------------------------------------- 1 | 66 | */ 67 | public function getClass(): string; 68 | 69 | /** 70 | * Clears internal FileMap of pending files. 71 | */ 72 | public function clear(): void; 73 | } 74 | -------------------------------------------------------------------------------- /tests/Validator/Constraint/FileTest.php: -------------------------------------------------------------------------------- 1 | maxSize); 18 | } 19 | 20 | public function testNormalizeKb(): void 21 | { 22 | $file = new File(maxSize: '1k'); 23 | 24 | self::assertSame(1000, $file->maxSize); 25 | } 26 | 27 | public function testNormalizeMb(): void 28 | { 29 | $file = new File(maxSize: '1M'); 30 | 31 | self::assertSame(1000000, $file->maxSize); 32 | } 33 | 34 | public function testNormalizeKi(): void 35 | { 36 | $file = new File(maxSize: '1Ki'); 37 | 38 | self::assertSame(1024, $file->maxSize); 39 | } 40 | 41 | public function testNormalizeMi(): void 42 | { 43 | $file = new File(maxSize: '1Mi'); 44 | 45 | self::assertSame(1048576, $file->maxSize); 46 | } 47 | 48 | public function testInvalid(): void 49 | { 50 | $this->expectException(ConstraintDefinitionException::class); 51 | $this->expectExceptionMessage('"1 gigabyte" is not a valid maximum size.'); 52 | $this->expectExceptionCode(0); 53 | 54 | new File(maxSize: '1 gigabyte'); 55 | } 56 | 57 | public function testSingleMimeType(): void 58 | { 59 | $file = new File(mimeTypes: 'image/jpg'); 60 | 61 | self::assertCount(1, $file->mimeTypes); 62 | self::assertSame('image/jpg', $file->mimeTypes[0]); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Form/EventListener/FileUploadListener.php: -------------------------------------------------------------------------------- 1 | fileManager = $fileManager; 22 | $this->multiple = $multiple; 23 | } 24 | 25 | public static function getSubscribedEvents(): array 26 | { 27 | return [ 28 | FormEvents::SUBMIT => 'submit', 29 | ]; 30 | } 31 | 32 | public function submit(FormEvent $event): void 33 | { 34 | /** @var SplFileInfo|SplFileInfo[]|null $uploadedFile */ 35 | $uploadedFile = $event->getForm()->get('file')->getData(); 36 | 37 | if ($uploadedFile !== null) { 38 | $event->setData($this->transform($uploadedFile)); 39 | } 40 | } 41 | 42 | /** 43 | * @param SplFileInfo|SplFileInfo[] $data 44 | * @return File|File[] 45 | */ 46 | private function transform($data) 47 | { 48 | if ($this->multiple) { 49 | /** @var SplFileInfo[] $data */ 50 | return array_map( 51 | fn (SplFileInfo $file): File => $this->fileManager->upload($file), 52 | $data 53 | ); 54 | } else { 55 | /** @var SplFileInfo $data */ 56 | return $this->fileManager->upload($data); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/PathResolver/AwsS3PathResolverTest.php: -------------------------------------------------------------------------------- 1 | manager = $this->createMock(ManagerInterface::class); 29 | $this->s3Client = new S3Client([ 30 | 'region' => 'us-west-2', 31 | 'version' => '2006-03-01', 32 | 'credentials' => static function (): PromiseInterface { 33 | return Create::promiseFor( 34 | new Credentials('key', 'secret') 35 | ); 36 | }, 37 | ]); 38 | 39 | $this->pathResolver = new PathResolver\AwsS3PathResolver($this->s3Client, 'bucket', $this->manager); 40 | } 41 | 42 | public function testGetPath(): void 43 | { 44 | $file = new File('original_filename.jpg', 125, '098f6bcd4621d373cade4e832627b4f6', 'image/jpeg'); 45 | $this->manager->expects($this->once())->method('getPathname')->with($file)->willReturn('pathname'); 46 | self::assertSame('https://bucket.s3.us-west-2.amazonaws.com/pathname', $this->pathResolver->getPath($file)); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Command/MigrateNamingStrategyCommand.php: -------------------------------------------------------------------------------- 1 | migrator = $migrator; 27 | $this->repository = $repository; 28 | } 29 | 30 | protected function execute(InputInterface $input, OutputInterface $output): int 31 | { 32 | $io = new SymfonyStyle($input, $output); 33 | $progressBar = $io->createProgressBar(); 34 | 35 | $totalMigrated = 0; 36 | $totalFailed = 0; 37 | 38 | $files = $this->repository->findAllForBatchProcessing(); 39 | foreach ($progressBar->iterate($files) as $file) { 40 | $migrated = $this->migrator->migrate($file); 41 | if ($migrated) { 42 | $totalMigrated++; 43 | $io->success('File ' . $file->getHash() . ' migrated'); 44 | } else { 45 | $totalFailed++; 46 | $io->warning('File ' . $file->getHash() . ' not migrated'); 47 | } 48 | } 49 | 50 | $io->note('Migrated: ' . (string)$totalMigrated . '. Failures: ' . (string)$totalFailed . '.'); 51 | 52 | return 0; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Preview/PreviewGenerator.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 30 | $this->generators = $generators; 31 | $this->dimension = $dimension; 32 | } 33 | 34 | /** 35 | * @throws NoPreviewGeneratorFound 36 | */ 37 | public function generate(File $file): File 38 | { 39 | $preview = $this->manager->upload($this->generatePreview($file)); 40 | 41 | if ($preview instanceof MutableFile) { 42 | $filename = pathinfo($file->getOriginalFilename(), PATHINFO_FILENAME); 43 | $extension = pathinfo($file->getOriginalFilename(), PATHINFO_EXTENSION); 44 | $preview->setOriginalFilename(sprintf('%s_preview.%s', $filename, $extension)); 45 | } 46 | 47 | return $preview; 48 | } 49 | 50 | private function generatePreview(File $file): SplFileInfo 51 | { 52 | foreach ($this->generators as $generator) { 53 | if ($generator->supports($file)) { 54 | return $generator->generate($file, $this->dimension); 55 | } 56 | } 57 | 58 | throw NoPreviewGeneratorFound::instance($file); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/PathResolverManager.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 19 | $this->pathResolver = $pathResolver; 20 | } 21 | 22 | public function upload(SplFileInfo $splFileInfo): File 23 | { 24 | return $this->manager->upload($splFileInfo); 25 | } 26 | 27 | public function getPathname(File $file): string 28 | { 29 | return $this->manager->getPathname($file); 30 | } 31 | 32 | public function read(File $file): string 33 | { 34 | return $this->manager->read($file); 35 | } 36 | 37 | public function readStream(File $file) 38 | { 39 | return $this->manager->readStream($file); 40 | } 41 | 42 | public function write(MutableFile $file, SplFileInfo $splFileInfo): void 43 | { 44 | $this->manager->write($file, $splFileInfo); 45 | } 46 | 47 | public function moveFile(File $file): void 48 | { 49 | $this->manager->moveFile($file); 50 | } 51 | 52 | public function remove(File $file): void 53 | { 54 | $this->manager->remove($file); 55 | } 56 | 57 | public function getClass(): string 58 | { 59 | return $this->manager->getClass(); 60 | } 61 | 62 | public function getPath(File $file): string 63 | { 64 | return $this->pathResolver->getPath($file); 65 | } 66 | 67 | public function clear(): void 68 | { 69 | $this->manager->clear(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Preview/ImagePreviewGenerator.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 32 | $this->imagine = $imagine; 33 | $this->format = $format; 34 | $this->transformation = $transformation; 35 | } 36 | 37 | public function supports(File $file): bool 38 | { 39 | return stripos($file->getMimeType(), 'image/') !== false; 40 | } 41 | 42 | public function generate(File $file, DimensionInterface $dimension): SplFileInfo 43 | { 44 | $image = $this->imagine->read($this->manager->readStream($file)); 45 | $image = $image->thumbnail(new Box($dimension->getWidth(), $dimension->getHeight())); 46 | 47 | if ($this->transformation !== null) { 48 | $this->transformation->apply($image); 49 | } 50 | 51 | $preview = new SplTempFileObject(); 52 | $preview->fwrite($image->get($this->getFormat($file))); 53 | 54 | return $preview; 55 | } 56 | 57 | private function getFormat(File $file): string 58 | { 59 | return $this->format ?? str_replace('image/', '', $file->getMimeType()); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Functional/NamingStrategy/AbstractStrategyTest.php: -------------------------------------------------------------------------------- 1 | namingStrategy = self::getContainer()->get(NamingStrategy::class); 21 | } 22 | 23 | final public function doesFileExists(File $file): bool 24 | { 25 | $filepath = ($this->namingStrategy->getDirectoryName($file) ?? "") . $this->namingStrategy->getFileName($file); 26 | 27 | return $this->flysystem->fileExists($filepath); 28 | } 29 | 30 | final public function testFileAfterCreation(): File 31 | { 32 | $file = $this->manager->upload(new SplFileObject(__DIR__ . '/../../files/image1.jpg')); 33 | 34 | $this->entityManager->persist($file); 35 | $this->entityManager->flush(); 36 | $this->entityManager->clear(); 37 | 38 | self::assertTrue($this->doesFileExists($file)); 39 | 40 | return $file; 41 | } 42 | 43 | final public function testFileAfterDeletion(): void 44 | { 45 | $file = $this->testFileAfterCreation(); 46 | 47 | $file = $this->entityManager->find($this->manager->getClass(), $file->getId()); 48 | 49 | $filepath = NamingStrategyUtility::getPathnameFromStrategy($this->namingStrategy, $file); 50 | 51 | $this->entityManager->remove($file); 52 | $this->assertTrue($this->flysystem->fileExists($filepath)); 53 | 54 | $this->entityManager->flush(); 55 | self::assertFalse($this->flysystem->fileExists($filepath)); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Form/Type/FileType.php: -------------------------------------------------------------------------------- 1 | fileManager = $fileManager; 22 | } 23 | 24 | public function buildForm(FormBuilderInterface $builder, array $options): void 25 | { 26 | $fileOptions = $options['input_options']; 27 | $fileOptions['mapped'] = false; 28 | $fileOptions['label'] = false; 29 | $fileOptions['multiple'] = $options['multiple']; 30 | $fileOptions['required'] = $options['required']; 31 | 32 | $builder->add('file', SymfonyFileType::class, $fileOptions); 33 | 34 | $builder->addEventSubscriber(new FileUploadListener($options['manager'], $options['multiple'])); 35 | } 36 | 37 | public function configureOptions(OptionsResolver $resolver): void 38 | { 39 | $resolver->setDefault( 40 | 'data_class', 41 | static fn (Options $options): ?string => $options['multiple'] ? null : $options['manager']->getClass() 42 | ); 43 | $resolver->setDefault('empty_data', null); 44 | $resolver->setDefault('input_options', []); 45 | $resolver->setDefault('multiple', false); 46 | $resolver->setDefault('manager', $this->fileManager); 47 | $resolver->setAllowedTypes('manager', ManagerInterface::class); 48 | } 49 | 50 | public function getBlockPrefix(): string 51 | { 52 | return 'arxy_file'; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Functional/Twig/FilesExtensionTest.php: -------------------------------------------------------------------------------- 1 | get(Environment::class); 46 | $formatted = $twig->render($twig->createTemplate('{{ bytes|format_bytes }}'), ['bytes' => $bytes]); 47 | 48 | self::assertEquals($expected, $formatted); 49 | } 50 | 51 | public function testGetContent(): void 52 | { 53 | $pathname = __DIR__ . '/../../files/lorem-ipsum.txt'; 54 | $file = $this->manager->upload(new SplFileObject($pathname)); 55 | 56 | /** @var Environment $twig */ 57 | $twig = self::getContainer()->get(Environment::class); 58 | $formatted = $twig->render($twig->createTemplate('{{ file|file_content }}'), ['file' => $file]); 59 | self::assertSame(file_get_contents($pathname), $formatted); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/PathResolver/DelegatingPathResolverTest.php: -------------------------------------------------------------------------------- 1 | pathResolver1 = $this->createMock(PathResolver::class); 26 | $this->pathResolver2 = $this->createMock(PathResolver::class); 27 | $this->pathResolver = new DelegatingPathResolver( 28 | [ 29 | File::class => $this->pathResolver1, 30 | File2::class => $this->pathResolver2, 31 | ] 32 | ); 33 | } 34 | 35 | public function testGetPath(): void 36 | { 37 | $file1 = new File('original_filename.jpg', 125, '1234567', 'image/jpeg'); 38 | $this->pathResolver1->expects($this->once())->method('getPath')->with($file1)->willReturn(File::class); 39 | self::assertSame(File::class, $this->pathResolver->getPath($file1)); 40 | 41 | $file2 = new File2('original_filename.jpg', 125, '1234567', 'image/jpeg'); 42 | $this->pathResolver2->expects($this->once())->method('getPath')->with($file2)->willReturn(File2::class); 43 | self::assertSame(File2::class, $this->pathResolver->getPath($file2)); 44 | } 45 | 46 | public function testNotManagedFile(): void 47 | { 48 | $this->expectException(LogicException::class); 49 | $this->expectExceptionMessage('No resolver for ' . File3::class); 50 | $this->pathResolver->getPath(new File3('original_filename.jpg', 125, '1234567', 'image/jpeg')); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/NamingStrategy/DirectoryPrefixStrategyTest.php: -------------------------------------------------------------------------------- 1 | getFile(), 'cache/1/2/3/', '123'); 33 | } 34 | 35 | private function getFile(): \Arxy\FilesBundle\Model\File 36 | { 37 | $file = new File('original_filename.jpg', 125, '098f6bcd4621d373cade4e832627b4f6', 'image/jpeg'); 38 | $file->setId(12345); 39 | 40 | return $file; 41 | } 42 | 43 | public function testIfDecoratedStrategyIsNull(): void 44 | { 45 | $strategy = new NamingStrategy\DirectoryPrefixStrategy( 46 | new class () implements NamingStrategy { 47 | public function getDirectoryName(\Arxy\FilesBundle\Model\File $file): ?string 48 | { 49 | return null; 50 | } 51 | 52 | public function getFileName(\Arxy\FilesBundle\Model\File $file): string 53 | { 54 | return '123'; 55 | } 56 | }, 57 | 'cache/' 58 | ); 59 | 60 | self::assertSame('cache/', $strategy->getDirectoryName($this->getFile())); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/PathResolver/AwsPreSignedS3PathResolverTest.php: -------------------------------------------------------------------------------- 1 | manager = $this->createMock(ManagerInterface::class); 30 | $this->s3Client = new S3Client([ 31 | 'region' => 'us-west-2', 32 | 'version' => '2006-03-01', 33 | 'credentials' => static function (): PromiseInterface { 34 | return Create::promiseFor( 35 | new Credentials('key', 'secret') 36 | ); 37 | }, 38 | ]); 39 | 40 | $this->pathResolver = new PathResolver\AwsS3PreSignedPathResolver( 41 | $this->s3Client, 42 | 'bucket', 43 | $this->manager, 44 | new DateInterval( 45 | 'P1D' 46 | ) 47 | ); 48 | } 49 | 50 | public function testGetPath(): void 51 | { 52 | $file = new File('original_filename.jpg', 125, '098f6bcd4621d373cade4e832627b4f6', 'image/jpeg'); 53 | $this->manager->expects($this->once())->method('getPathname')->with($file)->willReturn('pathname'); 54 | self::assertStringContainsString( 55 | 'https://bucket.s3.us-west-2.amazonaws.com/pathname', 56 | $this->pathResolver->getPath($file) 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Validator/Constraint/File.php: -------------------------------------------------------------------------------- 1 | */ 26 | public array $mimeTypes = []; 27 | 28 | /** 29 | * @param array|string $mimeTypes 30 | * @param array|null $groups 31 | */ 32 | public function __construct( 33 | int|string|null $maxSize = null, 34 | public string $maxSizeMessage = 'The file is too large ({{ size }}). Allowed maximum size is {{ limit }}.', 35 | array|string $mimeTypes = [], 36 | public string $mimeTypesMessage = 'The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}.', 37 | array $groups = null, 38 | ) { 39 | if (is_string($maxSize)) { 40 | $maxSize = $this->normalizeBinaryFormat($maxSize); 41 | } 42 | $this->maxSize = $maxSize; 43 | 44 | if (!is_array($mimeTypes)) { 45 | $mimeTypes = [$mimeTypes]; 46 | } 47 | $this->mimeTypes = $mimeTypes; 48 | parent::__construct(groups: $groups); 49 | } 50 | 51 | private function normalizeBinaryFormat(string $maxSize): int 52 | { 53 | $original = $maxSize; 54 | try { 55 | if (stripos($maxSize, 'B') === false) { 56 | $maxSize .= 'B'; 57 | } 58 | 59 | return (int)parse($maxSize)->numberOfBytes(); 60 | } catch (Exception $e) { 61 | throw new ConstraintDefinitionException(sprintf('"%s" is not a valid maximum size.', $original), 0, $e); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/Functional/Kernel.php: -------------------------------------------------------------------------------- 1 | testCase = $testCase; 28 | } 29 | 30 | public function config(string $config): void 31 | { 32 | $this->config = realpath($config); 33 | } 34 | 35 | public function bundles(array $bundles): void 36 | { 37 | $this->additionalBundles = $bundles; 38 | } 39 | 40 | public function getProjectDir(): string 41 | { 42 | return __DIR__; 43 | } 44 | 45 | private function getVarDir(): string 46 | { 47 | return __DIR__ . '/var/files-bundle-' . md5($this->testCase); 48 | } 49 | 50 | public function getCacheDir() 51 | { 52 | return $this->getVarDir() . '/cache'; 53 | } 54 | 55 | public function getLogDir() 56 | { 57 | return $this->getVarDir() . '/log'; 58 | } 59 | 60 | public function registerBundles(): array 61 | { 62 | return array_merge( 63 | [ 64 | new FrameworkBundle(), 65 | new DoctrineBundle(), 66 | new FlysystemBundle(), 67 | new ArxyFilesBundle(), 68 | ], 69 | $this->additionalBundles 70 | ); 71 | } 72 | 73 | public function registerContainerConfiguration(LoaderInterface $loader): void 74 | { 75 | $loader->load(__DIR__ . '/config_base.yml'); 76 | $loader->load($this->config); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/PathResolver/AzureBlobStoragePathResolverTest.php: -------------------------------------------------------------------------------- 1 | manager = $this->createMock(ManagerInterface::class); 26 | $this->blobRestProxy = $this->createMock(BlobRestProxy::class); 27 | $this->pathResolver = new PathResolver\AzureBlobStoragePathResolver( 28 | $this->blobRestProxy, 29 | 'azure-container', 30 | $this->manager, 31 | ); 32 | } 33 | 34 | public function testGetPath(): void 35 | { 36 | $file = new File('original_filename.jpg', 125, '098f6bcd4621d373cade4e832627b4f6', 'image/jpeg'); 37 | 38 | $this->manager->expects($this->once())->method('getPathname')->with($file)->willReturn('pathname'); 39 | 40 | $this->blobRestProxy->expects($this->once()) 41 | ->method('getBlobUrl') 42 | ->with('azure-container', 'pathname') 43 | ->willReturn('all good'); 44 | 45 | self::assertSame('all good', $this->pathResolver->getPath($file)); 46 | } 47 | 48 | public function testGetContainer(): void 49 | { 50 | self::assertSame('azure-container', $this->pathResolver->getContainer()); 51 | } 52 | 53 | public function testGetBlob(): void 54 | { 55 | $file = new File('original_filename.jpg', 125, '098f6bcd4621d373cade4e832627b4f6', 'image/jpeg'); 56 | 57 | $this->manager->expects($this->once())->method('getPathname')->with($file)->willReturn('all good'); 58 | 59 | self::assertSame('all good', $this->pathResolver->getBlob($file)); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Functional/Entity/News.php: -------------------------------------------------------------------------------- 1 | id; 39 | } 40 | 41 | public function setId(?int $id): void 42 | { 43 | $this->id = $id; 44 | } 45 | 46 | public function getFile(): ?File 47 | { 48 | return $this->file; 49 | } 50 | 51 | public function setFile(?File $file): void 52 | { 53 | $this->file = $file; 54 | } 55 | 56 | public function getEmbeddableFile(): ?EmbeddableFile 57 | { 58 | return $this->embeddableFile; 59 | } 60 | 61 | public function setEmbeddableFile(?EmbeddableFile $embeddableFile): void 62 | { 63 | $this->embeddableFile = $embeddableFile; 64 | } 65 | 66 | public function getEmbeddableFile1(): ?EmbeddableFile 67 | { 68 | return $this->embeddableFile1; 69 | } 70 | 71 | public function setEmbeddableFile1(?EmbeddableFile $embeddableFile1): void 72 | { 73 | $this->embeddableFile1 = $embeddableFile1; 74 | } 75 | 76 | public function getEmbeddableFilePersistentPath(): ?EmbeddableFilePersistentPath 77 | { 78 | return $this->embeddableFilePersistentPath; 79 | } 80 | 81 | public function setEmbeddableFilePersistentPath(?EmbeddableFilePersistentPath $embeddableFilePersistentPath): void 82 | { 83 | $this->embeddableFilePersistentPath = $embeddableFilePersistentPath; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Storage/FlysystemStorage.php: -------------------------------------------------------------------------------- 1 | flysystem = $flysystem; 21 | } 22 | 23 | public function read(File $file, string $pathname): string 24 | { 25 | try { 26 | return $this->flysystem->read($pathname); 27 | } catch (FilesystemException $exception) { 28 | throw FileException::unableToRead($file, $exception); 29 | } 30 | } 31 | 32 | public function readStream(File $file, string $pathname) 33 | { 34 | try { 35 | return $this->flysystem->readStream($pathname); 36 | } catch (FilesystemException $exception) { 37 | throw FileException::unableToRead($file, $exception); 38 | } 39 | } 40 | 41 | public function write(File $file, string $pathname, $stream): void 42 | { 43 | try { 44 | $this->flysystem->writeStream($pathname, $stream); 45 | } catch (FilesystemException $exception) { 46 | throw FileException::unableToWrite($file, $exception); 47 | } 48 | } 49 | 50 | public function remove(File $file, string $pathname): void 51 | { 52 | try { 53 | $this->flysystem->delete($pathname); 54 | } catch (FilesystemException $exception) { 55 | throw FileException::unableToMove($file, $exception); 56 | } 57 | } 58 | 59 | public function migrate(File $file, string $oldPathname, string $newPathname): bool 60 | { 61 | try { 62 | if (!$this->flysystem->fileExists($oldPathname)) { 63 | return false; 64 | } 65 | 66 | $this->flysystem->move($oldPathname, $newPathname); 67 | 68 | return true; 69 | } catch (FilesystemException $exception) { 70 | throw new FileException($file, 'Unable to migrate file', $exception); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/PathResolver/AzureBlobStorageSASPathResolver.php: -------------------------------------------------------------------------------- 1 | pathResolver = $pathResolver; 27 | $this->signatureHelper = $signatureHelper; 28 | $this->parametersFactory = $factory; 29 | } 30 | 31 | public function getPath(File $file): string 32 | { 33 | return $this->pathResolver->getPath($file) . '?' . $this->generateSas($file); 34 | } 35 | 36 | private function generateSas(File $file): string 37 | { 38 | $parameters = $this->parametersFactory->create($file); 39 | $expiry = $parameters->getExpiry(); 40 | 41 | $expiry = DateTime::createFromImmutable($expiry); 42 | 43 | $start = $parameters->getStart(); 44 | if ($start !== null) { 45 | $start = DateTime::createFromImmutable($start); 46 | } 47 | 48 | return $this->signatureHelper->generateBlobServiceSharedAccessSignatureToken( 49 | Resources::RESOURCE_TYPE_BLOB, 50 | sprintf('%s/%s', $this->pathResolver->getContainer(), $this->pathResolver->getBlob($file)), 51 | 'r', 52 | $expiry, 53 | $start ?? "", 54 | $parameters->getIp() ?? "", 55 | 'https', 56 | $parameters->getIdentifier() ?? "", 57 | $parameters->getCacheControl() ?? "", 58 | $parameters->getContentDisposition() ?? "", 59 | $parameters->getContentEncoding() ?? "", 60 | $parameters->getContentLanguage() ?? "", 61 | $parameters->getContentType() ?? "", 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/FileDownloaderTest.php: -------------------------------------------------------------------------------- 1 | filesystem = new Filesystem(new InMemoryFilesystemAdapter()); 29 | 30 | $this->manager = new Manager( 31 | File::class, 32 | new FlysystemStorage($this->filesystem), 33 | /** @implements NamingStrategy */ 34 | new class implements NamingStrategy { 35 | public function getDirectoryName(\Arxy\FilesBundle\Model\File $file): ?string 36 | { 37 | return null; 38 | } 39 | 40 | public function getFileName(\Arxy\FilesBundle\Model\File $file): string 41 | { 42 | return (string)$file->getId(); 43 | } 44 | }, 45 | new FileRepository(), 46 | ); 47 | 48 | $this->downloader = new FileDownloader($this->manager); 49 | } 50 | 51 | public function testDownloadAsSplFilePersisted(): void 52 | { 53 | $file = $this->manager->upload(new \SplFileObject(__DIR__ . '/files/image1.jpg')); 54 | $this->manager->moveFile($file); 55 | 56 | $splFile = $this->downloader->downloadAsSplFile($file); 57 | 58 | self::assertFileEquals($splFile->getPathname(), __DIR__ . '/files/image1.jpg'); 59 | } 60 | 61 | public function testDownloadAsSplFileNotPersisted(): void 62 | { 63 | $file = $this->manager->upload(new \SplFileObject(__DIR__ . '/files/image1.jpg')); 64 | 65 | $splFile = $this->downloader->downloadAsSplFile($file); 66 | 67 | self::assertFileEquals($splFile->getPathname(), __DIR__ . '/files/image1.jpg'); 68 | } 69 | } -------------------------------------------------------------------------------- /tests/Functional/Preview/PreviewGeneratorMessengerTest.php: -------------------------------------------------------------------------------- 1 | manager->upload(new SplFileInfo(__DIR__ . '/../../files/image1.jpg')); 26 | assert($file instanceof FileWithPreview); 27 | 28 | $this->entityManager->persist($file); 29 | $this->entityManager->flush(); 30 | 31 | self::assertNotNull($file->getPreview()); 32 | // $previewManager = self::getContainer()->get('preview'); 33 | 34 | // $expectedFilename = __DIR__.'/../../files/image1_preview.jpg'; 35 | // 36 | // $expectedMd5 = md5_file($expectedFilename); 37 | // self::assertSame($expectedMd5, md5($previewManager->read($file->getPreview()))); 38 | // self::assertSame($expectedMd5, $file->getPreview()->getHash()); 39 | // 40 | // 41 | // $expectedFilesize = filesize($expectedFilename); 42 | // self::assertSame($expectedFilesize, strlen($previewManager->read($file->getPreview()))); 43 | // self::assertSame($expectedFilesize, $file->getPreview()->getSize()); 44 | 45 | // self::assertSame('image1_preview.jpg', $file->getPreview()->getOriginalFilename()); 46 | } 47 | 48 | public function testPreviewWrite(): void 49 | { 50 | $file = $this->manager->upload(new SplFileInfo(__DIR__ . '/../../files/image1.jpg')); 51 | assert($file instanceof FileWithPreview); 52 | 53 | $this->entityManager->persist($file); 54 | $this->entityManager->flush(); 55 | 56 | $preview1 = $file->getPreview(); 57 | self::assertNotNull($preview1); 58 | 59 | $this->manager->write($file, new SplFileInfo(__DIR__ . '/../../files/image2.jpg')); 60 | 61 | self::assertNotSame($preview1, $file->getPreview()); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Validator/Constraint/FileValidator.php: -------------------------------------------------------------------------------- 1 | maxSize !== null) { 30 | $limitInBytes = $constraint->maxSize; 31 | $sizeInBytes = $value->getSize(); 32 | 33 | if ($sizeInBytes > $limitInBytes) { 34 | $this->context->buildViolation($constraint->maxSizeMessage) 35 | ->setParameter('{{ size }}', $this->humanizeBytes($sizeInBytes)) 36 | ->setParameter('{{ limit }}', $this->humanizeBytes($limitInBytes)) 37 | ->addViolation(); 38 | } 39 | } 40 | 41 | if (count($constraint->mimeTypes) > 0) { 42 | $mimeTypes = $constraint->mimeTypes; 43 | $mime = $value->getMimeType(); 44 | 45 | foreach ($mimeTypes as $mimeType) { 46 | if ($mimeType === $mime) { 47 | return; 48 | } 49 | 50 | if ($discrete = strstr($mimeType, '/*', true)) { 51 | if (strstr($mime, '/', true) === $discrete) { 52 | return; 53 | } 54 | } 55 | } 56 | 57 | $this->context->buildViolation($constraint->mimeTypesMessage) 58 | ->setParameter('{{ type }}', $this->formatValue($mime)) 59 | ->setParameter('{{ types }}', $this->formatValues($mimeTypes)) 60 | ->addViolation(); 61 | } 62 | } 63 | 64 | private function humanizeBytes(int $bytes): string 65 | { 66 | return bytes($bytes)->format(2, ' '); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/Functional/AbstractFunctionalTest.php: -------------------------------------------------------------------------------- 1 | buildDb($kernel); 26 | 27 | $this->entityManager = static::getContainer() 28 | ->get('doctrine') 29 | ->getManager(); 30 | 31 | $this->manager = static::getContainer()->get('public'); 32 | $this->flysystem = static::getContainer()->get('in_memory'); 33 | } 34 | 35 | protected function tearDown(): void 36 | { 37 | parent::tearDown(); 38 | 39 | $this->entityManager->close(); 40 | unset($this->entityManager); 41 | 42 | $this->manager->clear(); 43 | unset($this->manager); 44 | 45 | unset($this->flysystem); 46 | } 47 | 48 | protected static function getKernelClass() 49 | { 50 | return Kernel::class; 51 | } 52 | 53 | protected static function createKernel(array $options = []) 54 | { 55 | $kernel = parent::createKernel($options); 56 | assert($kernel instanceof Kernel); 57 | $kernel->config(static::getConfig()); 58 | $kernel->bundles(static::getBundles()); 59 | $kernel->setTestCase(static::class); 60 | 61 | return $kernel; 62 | } 63 | 64 | abstract protected static function getConfig(): string; 65 | 66 | abstract protected static function getBundles(): array; 67 | 68 | private function buildDb($kernel): void 69 | { 70 | $application = new Application($kernel); 71 | $application->setAutoExit(false); 72 | 73 | $application->run( 74 | new ArrayInput( 75 | [ 76 | 'doctrine:schema:create', 77 | ] 78 | ), 79 | new ConsoleOutput() 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/Command/MigrateNamingStrategyCommandTest.php: -------------------------------------------------------------------------------- 1 | manager = $this->createMock(ManagerInterface::class); 31 | $this->repository = $this->createMock(Repository::class); 32 | $this->migrator = $this->createMock(MigratorInterface::class); 33 | 34 | $this->command = new MigrateNamingStrategyCommand($this->migrator, $this->repository); 35 | } 36 | 37 | public function testExecute(): void 38 | { 39 | $file1 = new File('filename', 125, '098f6bcd4621d373cade4e832627b4f6', 'image/jpeg'); 40 | $file1->setId(1); 41 | 42 | $file2 = new File('filename', 125, '098f6bcd4621d373cade4e832627b4f6', 'image/jpeg'); 43 | $file2->setId(2); 44 | $this->repository->expects($this->once())->method('findAllForBatchProcessing')->willReturn([$file1, $file2]); 45 | 46 | $this->migrator 47 | ->expects($this->exactly(2)) 48 | ->method('migrate')->withConsecutive( 49 | [$this->identicalTo($file1)], 50 | [$this->identicalTo($file2)] 51 | ) 52 | ->will($this->onConsecutiveCalls(true, false)); 53 | 54 | $commandTester = new CommandTester($this->command); 55 | self::assertSame(0, $commandTester->execute([])); 56 | 57 | $output = $commandTester->getDisplay(); 58 | 59 | self::assertStringContainsString('File 098f6bcd4621d373cade4e832627b4f6 migrated', $output); 60 | self::assertStringContainsString('File 098f6bcd4621d373cade4e832627b4f6 not migrated', $output); 61 | self::assertStringContainsString('Migrated: 1. Failures: 1.', $output); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Preview/DimensionTest.php: -------------------------------------------------------------------------------- 1 | expectNotToPerformAssertions(); 26 | new Dimension($width, $height); 27 | } 28 | 29 | public function constructorExceptionsDataProvider(): iterable 30 | { 31 | yield [ 32 | 0, 33 | 1, 34 | InvalidArgumentException::class, 35 | 'Length of either side cannot be 0 or negative, current size is 0x1', 36 | ]; 37 | yield [ 38 | 1, 39 | 0, 40 | InvalidArgumentException::class, 41 | 'Length of either side cannot be 0 or negative, current size is 1x0', 42 | ]; 43 | yield [ 44 | 0, 45 | 0, 46 | InvalidArgumentException::class, 47 | 'Length of either side cannot be 0 or negative, current size is 0x0', 48 | ]; 49 | yield [ 50 | -1, 51 | 1, 52 | InvalidArgumentException::class, 53 | 'Length of either side cannot be 0 or negative, current size is -1x1', 54 | ]; 55 | yield [ 56 | 1, 57 | -1, 58 | InvalidArgumentException::class, 59 | 'Length of either side cannot be 0 or negative, current size is 1x-1', 60 | ]; 61 | yield [ 62 | -1, 63 | -1, 64 | InvalidArgumentException::class, 65 | 'Length of either side cannot be 0 or negative, current size is -1x-1', 66 | ]; 67 | yield [null, 1, TypeError::class]; 68 | yield [1, null, TypeError::class]; 69 | } 70 | 71 | /** 72 | * @dataProvider constructorExceptionsDataProvider 73 | */ 74 | public function testConstructorExceptions( 75 | ?int $width, 76 | ?int $height, 77 | string $expectException, 78 | ?string $expectExceptionMessage = null 79 | ): void { 80 | $this->expectException($expectException); 81 | $this->expectExceptionMessage($expectExceptionMessage); 82 | 83 | new Dimension($width, $height); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Utility/DownloadUtility.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 26 | } 27 | 28 | public function createResponse(File $file): StreamedResponse 29 | { 30 | $response = new StreamedResponse(); 31 | $response->headers->set('Content-Type', $file->getMimeType()); 32 | $response->setPublic(); 33 | $response->setEtag($file->getHash()); 34 | 35 | if ($file instanceof DownloadableFile) { 36 | $expireAt = $file->getExpireAt(); 37 | $response->setExpires($expireAt); 38 | $response->setLastModified($file->getModifiedAt()); 39 | 40 | $contentDisposition = HeaderUtils::makeDisposition( 41 | $file->isForceDownload() ? HeaderUtils::DISPOSITION_ATTACHMENT : HeaderUtils::DISPOSITION_INLINE, 42 | $file->getName() ?? u($file->getOriginalFilename())->ascii()->toString() 43 | ); 44 | } else { 45 | $expireAt = new DateTimeImmutable("+30 days"); 46 | $response->setExpires($expireAt); 47 | $response->setLastModified($file->getCreatedAt()); 48 | 49 | $contentDisposition = HeaderUtils::makeDisposition( 50 | HeaderUtils::DISPOSITION_ATTACHMENT, 51 | u($file->getOriginalFilename())->ascii()->toString() 52 | ); 53 | } 54 | 55 | $response->headers->set('Content-Length', (string)$file->getSize()); 56 | $response->headers->set('Content-Disposition', $contentDisposition); 57 | 58 | $response->setCallback( 59 | function () use ($file): void { 60 | $stream = $this->manager->readStream($file); 61 | 62 | $out = ErrorHandler::wrap(static fn () => fopen('php://output', 'wb')); 63 | ErrorHandler::wrap(static fn (): int => stream_copy_to_stream($stream, $out)); 64 | ErrorHandler::wrap(static fn (): bool => fclose($out)); 65 | ErrorHandler::wrap(static fn (): bool => fclose($stream)); 66 | } 67 | ); 68 | 69 | return $response; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Functional/Preview/PreviewGeneratorTest.php: -------------------------------------------------------------------------------- 1 | manager->upload(new SplFileInfo(__DIR__ . '/../../files/image1.jpg')); 26 | assert($file instanceof FileWithPreview); 27 | 28 | $this->entityManager->persist($file); 29 | $this->entityManager->flush(); 30 | 31 | self::assertNotNull($file->getPreview()); 32 | // $previewManager = self::getContainer()->get('preview'); 33 | // 34 | // $expectedFilename = __DIR__.'/../../files/image1_preview.jpg'; 35 | 36 | // $expectedMd5 = md5_file($expectedFilename); 37 | // self::assertSame($expectedMd5, md5($previewManager->read($file->getPreview()))); 38 | // self::assertSame($expectedMd5, $file->getPreview()->getHash()); 39 | 40 | // $expectedFilesize = filesize($expectedFilename); 41 | // self::assertSame($expectedFilesize, strlen($previewManager->read($file->getPreview()))); 42 | // self::assertSame($expectedFilesize, $file->getPreview()->getSize()); 43 | 44 | // self::assertSame('image1_preview.jpg', $file->getPreview()->getOriginalFilename()); 45 | } 46 | 47 | public function testPreviewWrite(): void 48 | { 49 | $file = $this->manager->upload(new SplFileInfo(__DIR__ . '/../../files/image1.jpg')); 50 | assert($file instanceof FileWithPreview); 51 | 52 | $this->entityManager->persist($file); 53 | $this->entityManager->flush(); 54 | 55 | $preview1 = $file->getPreview(); 56 | self::assertNotNull($preview1); 57 | 58 | $this->manager->write($file, new SplFileInfo(__DIR__ . '/../../files/image2.jpg')); 59 | 60 | self::assertNotSame($preview1, $file->getPreview()); 61 | } 62 | 63 | public function testNotSupportedFile(): void 64 | { 65 | $file = $this->manager->upload(new SplFileInfo(__DIR__ . '/../../files/lorem-ipsum.pdf')); 66 | assert($file instanceof FileWithPreview); 67 | 68 | $this->entityManager->persist($file); 69 | $this->entityManager->flush(); 70 | 71 | self::assertNull($file->getPreview()); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/FileMap.php: -------------------------------------------------------------------------------- 1 | */ 24 | private array $map = []; 25 | /** @var array */ 26 | private array $pendingFiles = []; 27 | 28 | /** 29 | * @param array $files 30 | * @param array $splFiles 31 | */ 32 | public function __construct(array $files = [], array $splFiles = []) 33 | { 34 | $this->map = $files; 35 | $this->pendingFiles = $splFiles; 36 | } 37 | 38 | /** 39 | * @return T|null 40 | */ 41 | public function findByHashAndSize(string $hash, int $size): ?File 42 | { 43 | foreach ($this->pendingFiles as $file) { 44 | if ($file->getHash() === $hash && $file->getSize() === $size) { 45 | return $file; 46 | } 47 | } 48 | 49 | return null; 50 | } 51 | 52 | /** 53 | * @param T $file 54 | * @param S $fileInfo 55 | */ 56 | public function put(File $file, SplFileInfo $fileInfo): void 57 | { 58 | $id = $this->getObjectId($file); 59 | $this->map[$id] = $fileInfo; 60 | $this->pendingFiles[$id] = $file; 61 | } 62 | 63 | /** 64 | * @param T $file 65 | * @return S 66 | */ 67 | public function get(File $file): SplFileInfo 68 | { 69 | if (!$this->has($file)) { 70 | throw new OutOfBoundsException( 71 | sprintf( 72 | 'File %s not found in map', 73 | FileUtility::toString($file) 74 | ) 75 | ); 76 | } 77 | 78 | return $this->map[$this->getObjectId($file)]; 79 | } 80 | 81 | /** 82 | * @param T $file 83 | */ 84 | public function has(File $file): bool 85 | { 86 | return isset($this->map[$this->getObjectId($file)]); 87 | } 88 | 89 | /** 90 | * @param T $file 91 | */ 92 | public function remove(File $file): void 93 | { 94 | $id = $this->getObjectId($file); 95 | unset($this->map[$id]); 96 | unset($this->pendingFiles[$id]); 97 | } 98 | 99 | /** 100 | * @param T $file 101 | */ 102 | private function getObjectId(File $file): int 103 | { 104 | return spl_object_id($file); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arxy/files", 3 | "type": "symfony-bundle", 4 | "authors": [ 5 | { 6 | "name": "Bozhidar Hristov", 7 | "email": "warxcell@gmail.com" 8 | } 9 | ], 10 | "require": { 11 | "php": "^8.0", 12 | "league/mime-type-detection": "^1.7", 13 | "gabrielelana/byte-units": "^0.5.0" 14 | }, 15 | "require-dev": { 16 | "symfony/validator": "*", 17 | "doctrine/orm": "*", 18 | "symfony/form": "*", 19 | "symfony/http-foundation": "*", 20 | "twig/twig": "*", 21 | "symfony/cache": "*", 22 | "symfony/asset": "*", 23 | "microsoft/azure-storage-blob": "*", 24 | "aws/aws-sdk-php": "*", 25 | "league/flysystem-memory": "^2.0", 26 | "phpunit/phpunit": "^9.5", 27 | "symfony/http-kernel": "*", 28 | "symfony/dependency-injection": "^5.2", 29 | "infection/infection": "^0.21.4", 30 | "symfony/symfony": "^4.4 | ^5.2", 31 | "doctrine/doctrine-bundle": "^2.3", 32 | "liip/imagine-bundle": "^2.6", 33 | "vimeo/psalm": "^4.7", 34 | "league/flysystem-bundle": "^2.0", 35 | "symfony/uid": "*", 36 | "imagine/imagine": "^1.2", 37 | "symfony/messenger": "*", 38 | "league/flysystem": "^2.0 | ^3.0", 39 | "squizlabs/php_codesniffer": "*", 40 | "slevomat/coding-standard": "^7.0" 41 | }, 42 | "suggest": { 43 | "league/flysystem": "Use FlySystem Storage to store contents of file", 44 | "symfony/validator": "Validate files against constraints", 45 | "symfony/form": "Integrate file upload with forms", 46 | "doctrine/orm": "Use Doctrine ORM as persistence layer", 47 | "twig/twig": "Use Arxy\\Files\\Twig\\FilesExtension::formatBytes to get formatted KiB/MiB/GB instead. Also for form template", 48 | "symfony/uid": "Required to use UuidV5Strategy/UuidV4Strategy Naming Strategy", 49 | "imagine/imagine": "Use ImagePreviewGenerator", 50 | "symfony/messenger": "Generate Previews asynchronous.", 51 | "symfony/event-dispatcher": "Usage of events.", 52 | "liip/imagine-bundle": "Generate image thumbnails", 53 | "aws/aws-sdk-php": "Generate file urls for files hosted on AWS S3", 54 | "microsoft/azure-storage-blob": "Generate file urls for files hosted on Microsoft Azure Blob Storage" 55 | }, 56 | "autoload": { 57 | "psr-4": { 58 | "Arxy\\FilesBundle\\": "src/" 59 | } 60 | }, 61 | "autoload-dev": { 62 | "psr-4": { 63 | "Arxy\\FilesBundle\\Tests\\": "tests/" 64 | } 65 | }, 66 | "config": { 67 | "allow-plugins": { 68 | "dealerdirect/phpcodesniffer-composer-installer": true, 69 | "infection/extension-installer": true 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/MigratorTest.php: -------------------------------------------------------------------------------- 1 | filesystem = $this->createMock(FilesystemOperator::class); 27 | 28 | $this->oldNamingStrategy = new class implements NamingStrategy { 29 | public function getDirectoryName(\Arxy\FilesBundle\Model\File $file): ?string 30 | { 31 | return null; 32 | } 33 | 34 | public function getFileName(\Arxy\FilesBundle\Model\File $file): string 35 | { 36 | return 'old_' . $file->getHash(); 37 | } 38 | }; 39 | 40 | $this->newNamingStrategy = new class implements NamingStrategy { 41 | public function getDirectoryName(\Arxy\FilesBundle\Model\File $file): ?string 42 | { 43 | return null; 44 | } 45 | 46 | public function getFileName(\Arxy\FilesBundle\Model\File $file): string 47 | { 48 | return 'new_' . $file->getHash(); 49 | } 50 | }; 51 | 52 | $this->migrator = new Migrator( 53 | new FlysystemStorage($this->filesystem), 54 | $this->oldNamingStrategy, 55 | $this->newNamingStrategy 56 | ); 57 | } 58 | 59 | public function testNotMigrated(): void 60 | { 61 | $file = new File('image2.jpg', 24053, '9aa1c5fc7c9388166d7ce7fd46648dd1', 'image/jpeg'); 62 | 63 | $this->filesystem->expects(self::once())->method('fileExists')->with('old_9aa1c5fc7c9388166d7ce7fd46648dd1') 64 | ->willReturn(true); 65 | 66 | $this->filesystem->expects(self::once())->method('move')->with( 67 | 'old_9aa1c5fc7c9388166d7ce7fd46648dd1', 68 | 'new_9aa1c5fc7c9388166d7ce7fd46648dd1' 69 | ); 70 | 71 | self::assertTrue($this->migrator->migrate($file)); 72 | } 73 | 74 | public function testMigrated(): void 75 | { 76 | $file = new File('image2.jpg', 24053, '9aa1c5fc7c9388166d7ce7fd46648dd1', 'image/jpeg'); 77 | 78 | $this->filesystem->expects(self::once())->method('fileExists')->with('old_9aa1c5fc7c9388166d7ce7fd46648dd1') 79 | ->willReturn(false); 80 | 81 | self::assertFalse($this->migrator->migrate($file)); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/EventListener/DoctrineORMListener.php: -------------------------------------------------------------------------------- 1 | class = $manager->getClass(); 25 | $this->manager = $manager; 26 | 27 | $this->move = static function (File $file) use ($manager): void { 28 | $manager->moveFile($file); 29 | }; 30 | $this->remove = static function (File $file) use ($manager): void { 31 | $manager->remove($file); 32 | }; 33 | } 34 | 35 | public function postPersist(LifecycleEventArgs $eventArgs): void 36 | { 37 | $entity = $eventArgs->getEntity(); 38 | $entityManager = $eventArgs->getEntityManager(); 39 | if ($this->supports($entity)) { 40 | ($this->move)($entity); 41 | } 42 | $this->handleEmbeddable($entityManager, $entity, $this->move); 43 | } 44 | 45 | public function postRemove(LifecycleEventArgs $eventArgs): void 46 | { 47 | $entity = $eventArgs->getEntity(); 48 | $entityManager = $eventArgs->getEntityManager(); 49 | 50 | if ($this->supports($entity)) { 51 | ($this->remove)($entity); 52 | } 53 | $this->handleEmbeddable($entityManager, $entity, $this->remove); 54 | } 55 | 56 | public function onClear(): void 57 | { 58 | $this->manager->clear(); 59 | } 60 | 61 | private function supports(object $entity): bool 62 | { 63 | return $entity instanceof $this->class; 64 | } 65 | 66 | private function handleEmbeddable( 67 | EntityManagerInterface $entityManager, 68 | object $entity, 69 | Closure $action 70 | ): void { 71 | $classMetadata = $entityManager->getClassMetadata(ClassUtils::getClass($entity)); 72 | 73 | foreach ($classMetadata->embeddedClasses as $property => $embeddedClass) { 74 | if (!is_a($embeddedClass['class'], $this->class, true)) { 75 | continue; 76 | } 77 | 78 | $refl = new ReflectionObject($entity); 79 | $reflProperty = $refl->getProperty($property); 80 | $reflProperty->setAccessible(true); 81 | /** @var File|null $file */ 82 | $file = $reflProperty->getValue($entity); 83 | 84 | if ($file === null) { 85 | continue; 86 | } 87 | $action($file); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/PathResolver/AzureBlobStorageSASParametersTest.php: -------------------------------------------------------------------------------- 1 | parameters = new PathResolver\AzureBlobStorageSASParameters(new DateTimeImmutable()); 19 | } 20 | 21 | public function testWithExpiry(): void 22 | { 23 | $expiry = new DateTimeImmutable(); 24 | 25 | $new = $this->parameters->withExpiry($expiry); 26 | 27 | self::assertNotSame($new, $this->parameters); 28 | self::assertSame($expiry, $new->getExpiry()); 29 | } 30 | 31 | public function testWithStart(): void 32 | { 33 | $expiry = new DateTimeImmutable(); 34 | 35 | $new = $this->parameters->withStart($expiry); 36 | 37 | self::assertNotSame($new, $this->parameters); 38 | self::assertSame($expiry, $new->getStart()); 39 | } 40 | 41 | public function testWithIp(): void 42 | { 43 | $new = $this->parameters->withIp('127.0.0.1'); 44 | 45 | self::assertNotSame($new, $this->parameters); 46 | self::assertSame('127.0.0.1', $new->getIp()); 47 | } 48 | 49 | public function testWithIdentifier(): void 50 | { 51 | $new = $this->parameters->withIdentifier('id'); 52 | 53 | self::assertNotSame($new, $this->parameters); 54 | self::assertSame('id', $new->getIdentifier()); 55 | } 56 | 57 | public function testWithCacheControl(): void 58 | { 59 | $new = $this->parameters->withCacheControl('cache-control'); 60 | 61 | self::assertNotSame($new, $this->parameters); 62 | self::assertSame('cache-control', $new->getCacheControl()); 63 | } 64 | 65 | public function testWithContentDisposition(): void 66 | { 67 | $new = $this->parameters->withContentDisposition('content-disposition'); 68 | 69 | self::assertNotSame($new, $this->parameters); 70 | self::assertSame('content-disposition', $new->getContentDisposition()); 71 | } 72 | 73 | public function testWithContentEncoding(): void 74 | { 75 | $new = $this->parameters->withContentEncoding('content-encoding'); 76 | 77 | self::assertNotSame($new, $this->parameters); 78 | self::assertSame('content-encoding', $new->getContentEncoding()); 79 | } 80 | 81 | public function testWithContentLanguage(): void 82 | { 83 | $new = $this->parameters->withContentLanguage('content-language'); 84 | 85 | self::assertNotSame($new, $this->parameters); 86 | self::assertSame('content-language', $new->getContentLanguage()); 87 | } 88 | 89 | public function testWithContentType(): void 90 | { 91 | $new = $this->parameters->withContentType('content-type'); 92 | 93 | self::assertNotSame($new, $this->parameters); 94 | self::assertSame('content-type', $new->getContentType()); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 17 | assert($rootNode instanceof ArrayNodeDefinition); 18 | 19 | // @formatter:off 20 | /** 21 | * @psalm-suppress PossiblyNullReference 22 | * @psalm-suppress PossiblyUndefinedMethod 23 | */ 24 | $rootNode 25 | ->fixXmlConfig('manager') 26 | ->children() 27 | ->booleanNode('form')->defaultTrue()->end() 28 | ->booleanNode('twig')->defaultTrue()->end() 29 | ->arrayNode('managers') 30 | ->useAttributeAsKey('name') 31 | ->arrayPrototype() 32 | ->performNoDeepMerging() 33 | ->children() 34 | ->enumNode('driver')->values(['orm'])->isRequired()->end() 35 | ->scalarNode('class')->isRequired()->end() 36 | ->arrayNode('storage') 37 | ->isRequired() 38 | ->beforeNormalization() 39 | ->ifString() 40 | ->then(static fn (string $value): array => ['service_id' => $value]) 41 | ->end() 42 | ->children() 43 | ->scalarNode('service_id')->isRequired()->cannotBeEmpty() 44 | ->end() 45 | ->end() 46 | ->end() 47 | ->arrayNode('naming_strategy') 48 | ->isRequired() 49 | ->beforeNormalization() 50 | ->ifString() 51 | ->then(static fn (string $value): array => ['service_id' => $value]) 52 | ->end() 53 | ->children() 54 | ->scalarNode('service_id')->isRequired()->cannotBeEmpty() 55 | ->end() 56 | ->end() 57 | ->end() 58 | ->scalarNode('repository')->defaultNull()->end() 59 | ->scalarNode('mime_type_detector')->defaultNull()->end() 60 | ->scalarNode('model_factory')->defaultNull()->end() 61 | ->end() 62 | ->end() 63 | ->defaultValue([]) 64 | ->end() 65 | ->end(); 66 | // @formatter:on 67 | return $treeBuilder; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/DelegatingManager.php: -------------------------------------------------------------------------------- 1 | , ManagerInterface> */ 20 | private array $managers = []; 21 | private ManagerInterface $manager; 22 | 23 | /** 24 | * @param ManagerInterface[] $managers 25 | */ 26 | public function __construct(array $managers, ManagerInterface $manager = null) 27 | { 28 | if (count($managers) === 0) { 29 | throw new InvalidArgumentException('You should pass at least one manager!'); 30 | } 31 | if ($manager !== null) { 32 | $this->manager = $manager; 33 | } else { 34 | $this->manager = reset($managers); 35 | } 36 | 37 | foreach ($managers as $manager) { 38 | $this->managers[$manager->getClass()] = $manager; 39 | } 40 | } 41 | 42 | /** 43 | * @param class-string $class 44 | * @return ManagerInterface 45 | * @throws LogicException if not manager is found for $class 46 | * @template T of File 47 | */ 48 | public function getManagerFor(string $class): ManagerInterface 49 | { 50 | if (!isset($this->managers[$class])) { 51 | throw new LogicException('No manager for ' . $class); 52 | } 53 | 54 | return $this->managers[$class]; 55 | } 56 | 57 | public function upload(SplFileInfo $splFileInfo): File 58 | { 59 | return $this->manager->upload($splFileInfo); 60 | } 61 | 62 | public function getPathname(File $file): string 63 | { 64 | return $this->getManagerForFile($file)->getPathname($file); 65 | } 66 | 67 | public function read(File $file): string 68 | { 69 | return $this->getManagerForFile($file)->read($file); 70 | } 71 | 72 | public function readStream(File $file) 73 | { 74 | return $this->getManagerForFile($file)->readStream($file); 75 | } 76 | 77 | public function write(MutableFile $file, SplFileInfo $splFileInfo): void 78 | { 79 | $this->getManagerForFile($file)->write($file, $splFileInfo); 80 | } 81 | 82 | public function moveFile(File $file): void 83 | { 84 | $this->getManagerForFile($file)->moveFile($file); 85 | } 86 | 87 | public function remove(File $file): void 88 | { 89 | $this->getManagerForFile($file)->remove($file); 90 | } 91 | 92 | public function getClass(): string 93 | { 94 | return $this->manager->getClass(); 95 | } 96 | 97 | public function clear(): void 98 | { 99 | foreach (array_merge($this->managers, [$this->manager->getClass() => $this->manager]) as $manager) { 100 | $manager->clear(); 101 | } 102 | } 103 | 104 | /** 105 | * @param T $file 106 | * @return ManagerInterface 107 | * @throws LogicException if not manager is found for $file 108 | * @template T of File 109 | */ 110 | private function getManagerForFile(File $file): ManagerInterface 111 | { 112 | foreach ($this->managers as $class => $manager) { 113 | if ($file instanceof $class) { 114 | return $manager; 115 | } 116 | } 117 | throw new LogicException('No manager for ' . get_class($file)); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/Command/VerifyConsistencyCommandTest.php: -------------------------------------------------------------------------------- 1 | manager = $this->createMock(ManagerInterface::class); 40 | $this->flysystem = $this->createMock(FilesystemOperator::class); 41 | $this->repository = $this->createMock(Repository::class); 42 | 43 | $this->command = new VerifyConsistencyCommand( 44 | new FlysystemStorage($this->flysystem), 45 | $this->manager, 46 | $this->repository 47 | ); 48 | } 49 | 50 | public function testExecute(): void 51 | { 52 | $file1 = new File('filename.txt', 5, '5a105e8b9d40e1329780d62ea2265d8a', 'text/plain2'); 53 | $file1->setId(1); 54 | 55 | $file2 = new File('filename', 125, '098f6bcd4621d373cade4e832627b4f6', 'image/jpeg'); 56 | $file2->setId(2); 57 | 58 | $files = [$file1, $file2]; 59 | $this->repository->expects(self::once())->method('findAllForBatchProcessing')->willReturn($files); 60 | 61 | $this->manager 62 | ->expects(self::exactly(2)) 63 | ->method('getPathname') 64 | ->withConsecutive(...array_map(static fn (File $file): array => [self::identicalTo($file)], $files)) 65 | ->will(self::onConsecutiveCalls('file1path', 'file2path')); 66 | 67 | $content = 'test'; 68 | $this->flysystem 69 | ->expects(self::exactly(2)) 70 | ->method('readStream') 71 | ->withConsecutive(['file1path']) 72 | ->will( 73 | self::onConsecutiveCalls( 74 | fopen('data://text/plain;base64,' . base64_encode($content), 'r'), 75 | self::throwException(new FileException($file2, 'File not found')) 76 | ) 77 | ); 78 | 79 | $commandTester = new CommandTester($this->command); 80 | self::assertSame(0, $commandTester->execute([])); 81 | 82 | $output = str_replace(PHP_EOL, '', $commandTester->getDisplay()); 83 | $output = preg_replace('/ +/', ' ', $output); 84 | 85 | self::assertStringContainsString('File file1path wrong size! Actual size: 4 bytes, expected 5 bytes!', $output); 86 | self::assertStringContainsString( 87 | 'File file1path wrong hash! Actual hash: 098f6bcd4621d373cade4e832627b4f6, expected 5a105e8b9d40e1329780d62ea2265d8a!', 88 | $output 89 | ); 90 | self::assertStringContainsString( 91 | 'File file1path wrong mimeType! Actual mimeType: text/plain, expected text/plain2!', 92 | $output 93 | ); 94 | self::assertStringContainsString('File file2path missing!', $output); 95 | self::assertStringContainsString('4 errors detected', $output); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/Utility/DownloadUtilityTest.php: -------------------------------------------------------------------------------- 1 | pathname = __DIR__ . '/../files/image1.jpg'; 31 | $flysystem = $this->createMock(FilesystemOperator::class); 32 | $flysystem->method('readStream')->willReturn(fopen($this->pathname, 'r')); 33 | 34 | $namingStrategy = $this->createMock(NamingStrategy::class); 35 | $this->manager = new Manager(File::class, new FlysystemStorage($flysystem), $namingStrategy); 36 | $this->downloadUtility = new DownloadUtility($this->manager); 37 | } 38 | 39 | public function createResponseProvider(): iterable 40 | { 41 | $file = new File('image1.jpg', 1234, '12345', 'image/jpeg'); 42 | $expiresAt = new DateTimeImmutable('+30 days'); 43 | yield [ 44 | $file, 45 | 'attachment; filename=image1.jpg', 46 | 'image/jpeg', 47 | $expiresAt->format('D, d M Y H:i'), 48 | $file->getCreatedAt()->format('D, d M Y H:i:s') . ' GMT', 49 | 1234, 50 | ]; 51 | 52 | yield [ 53 | new DownloadableFile( 54 | $file, 55 | 'my_name.jpg', 56 | false, 57 | new DateTimeImmutable('2021-04-29 15:00:00') 58 | ), 59 | 'inline; filename=my_name.jpg', 60 | 'image/jpeg', 61 | 'Thu, 29 Apr 2021 15:00:00 GMT', 62 | $file->getCreatedAt()->format('D, d M Y H:i:s') . ' GMT', 63 | 1234, 64 | ]; 65 | 66 | $mutableFile = new MutableFile('image1.jpg', 1234, '12345', 'image/jpeg'); 67 | $mutableFile->setModifiedAt(new DateTimeImmutable('2021-04-30 15:00:00')); 68 | yield [ 69 | new DownloadableFile( 70 | $mutableFile, 71 | 'my_name.jpg', 72 | false, 73 | new DateTimeImmutable('2021-05-29 15:00:00') 74 | ), 75 | 'inline; filename=my_name.jpg', 76 | 'image/jpeg', 77 | 'Sat, 29 May 2021 15:00:00 GMT', 78 | $mutableFile->getModifiedAt()->format('D, d M Y H:i:s') . ' GMT', 79 | 1234, 80 | ]; 81 | } 82 | 83 | /** 84 | * @dataProvider createResponseProvider 85 | */ 86 | public function testCreateResponse( 87 | \Arxy\FilesBundle\Model\File $file, 88 | string $expectedContentDisposition, 89 | string $expectedContentType, 90 | string $expectedExpires, 91 | string $expectedLastModified, 92 | int $expectedContentLength 93 | ): void { 94 | $response = $this->downloadUtility->createResponse($file); 95 | 96 | self::assertInstanceOf(StreamedResponse::class, $response); 97 | self::assertSame($expectedContentDisposition, $response->headers->get('Content-Disposition')); 98 | self::assertSame($expectedContentType, $response->headers->get('Content-Type')); 99 | self::assertStringContainsString($expectedExpires, $response->headers->get('Expires')); 100 | self::assertSame($expectedLastModified, $response->headers->get('Last-Modified')); 101 | self::assertSame((string)$expectedContentLength, $response->headers->get('Content-Length')); 102 | 103 | ob_start(); 104 | $response->sendContent(); 105 | $streamedContent = ob_get_clean(); 106 | 107 | self::assertSame(file_get_contents($this->pathname), $streamedContent); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/Form/Type/FileTypeTest.php: -------------------------------------------------------------------------------- 1 | manager = $this->createMock(ManagerInterface::class); 26 | 27 | parent::setUp(); 28 | } 29 | 30 | protected function getExtensions(): array 31 | { 32 | $type = new FileType($this->manager); 33 | 34 | return [ 35 | new PreloadedExtension([$type], []), 36 | ]; 37 | } 38 | 39 | protected function getTypeExtensions(): array 40 | { 41 | return [ 42 | new FormTypeHttpFoundationExtension(), 43 | ]; 44 | } 45 | 46 | public function testSingleUpload(): void 47 | { 48 | $uploadedFile = new UploadedFile(__DIR__ . '/../../files/image1.jpg', 'image1.jpg'); 49 | 50 | $file = new File('filename', 125, '098f6bcd4621d373cade4e832627b4f6', 'image/jpeg'); 51 | 52 | $this->manager->expects($this->once())->method('upload')->with($uploadedFile)->willReturn($file); 53 | $this->manager->expects($this->once())->method('getClass')->willReturn(File::class); 54 | 55 | $form = $this->factory->create(FileType::class, null); 56 | 57 | $form->submit(['file' => $uploadedFile]); 58 | 59 | $actual = $form->getData(); 60 | 61 | self::assertTrue($form->isSynchronized()); 62 | self::assertInstanceOf(File::class, $actual); 63 | self::assertSame($file, $actual); 64 | } 65 | 66 | public function testMultipleUpload(): void 67 | { 68 | $uploadedFile1 = new UploadedFile(__DIR__ . '/../../files/image1.jpg', 'image1.jpg'); 69 | $uploadedFile2 = new UploadedFile(__DIR__ . '/../../files/image2.jpg', 'image2.jpg'); 70 | 71 | $file1 = new File('filename', 125, '098f6bcd4621d373cade4e832627b4f6', 'image/jpeg'); 72 | $file2 = new File('filename', 125, '098f6bcd4621d373cade4e832627b4f6', 'image/jpeg'); 73 | 74 | $this->manager->expects($this->exactly(2)) 75 | ->method('upload') 76 | ->withConsecutive( 77 | [$this->identicalTo($uploadedFile1)], 78 | [$this->identicalTo($uploadedFile2)] 79 | ) 80 | ->will($this->onConsecutiveCalls($file1, $file2)); 81 | 82 | $this->manager->method('getClass')->willReturn(File::class); 83 | 84 | $form = $this->factory->create( 85 | FileType::class, 86 | null, 87 | [ 88 | 'multiple' => true, 89 | ] 90 | ); 91 | 92 | $form->submit(['file' => [$uploadedFile1, $uploadedFile2]]); 93 | 94 | $actual = $form->getData(); 95 | 96 | self::assertTrue($form->isSynchronized()); 97 | self::assertCount(2, $actual); 98 | self::assertSame($file1, $actual[0]); 99 | self::assertSame($file2, $actual[1]); 100 | } 101 | 102 | public function testInvalidManagerPassed(): void 103 | { 104 | $this->expectException(InvalidOptionsException::class); 105 | $this->expectExceptionMessage( 106 | 'The option "manager" with value stdClass is expected to be of type "Arxy\FilesBundle\ManagerInterface", but is of type "stdClass".' 107 | ); 108 | 109 | $this->factory->create( 110 | FileType::class, 111 | null, 112 | [ 113 | 'manager' => new stdClass(), 114 | ] 115 | ); 116 | } 117 | 118 | public function testLabelOfFileIsFalse(): void 119 | { 120 | $form = $this->factory->create( 121 | FileType::class, 122 | null, 123 | [ 124 | 'data_class' => File::class, 125 | ] 126 | ); 127 | 128 | self::assertFalse($form->get('file')->getConfig()->getOption('label')); 129 | } 130 | } 131 | --------------------------------------------------------------------------------