From 2fc715c74454804633f846d83a42680305b3fa4d Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 19 Feb 2026 23:48:57 +0000 Subject: [PATCH 1/4] Upgrade to Symfony 8.0 / PHP 8.5 with multi-storage, S3, and archive support - Multiple named storages with StorageResolver (file_system + S3 drivers) - Per-entity storage selection via #[Uploadable(storage: 'name')] - Behaviour::Archive with cross-backend download() support - Path traversal protection in resolvePath() and archive() - Upload/remove events with $entityClass for listener filtering - FileNormalizer with APP_URL base and absolute URI passthrough for S3/CDN - Uploadable class cache in FileSubscriber for performance - PHP attributes replace annotations (src/Mapping/Attribute/) - PHPStan level max compliance - Full test suite (114 tests) Co-Authored-By: Claude Opus 4.6 --- .claude/settings.local.json | 5 +- .php-cs-fixer.dist.php | 2 + CLAUDE.md | 146 +++++ README.md | 551 ++++++++++++++++++ composer.json | 76 ++- src/ChamberOrchestraFileBundle.php | 11 +- .../ChamberOrchestraFileExtension.php | 123 +++- src/DependencyInjection/Configuration.php | 105 ++-- src/Entity/FileTrait.php | 21 +- src/Entity/ImageTrait.php | 32 + src/Entity/RequiredFileTrait.php | 22 +- src/Entity/RequiredImageTrait.php | 32 + src/EventSubscriber/FileSubscriber.php | 195 +++++-- src/Events/AbstractEvent.php | 17 +- src/Events/PostRemoveEvent.php | 13 +- src/Events/PostUploadEvent.php | 33 ++ src/Events/PreRemoveEvent.php | 13 +- src/Events/PreUploadEvent.php | 32 + src/Exception/ExceptionInterface.php | 9 +- src/Exception/InvalidArgumentException.php | 11 +- src/Exception/ORM/MappingException.php | 23 +- src/Exception/RuntimeException.php | 11 +- src/Exception/UnexpectedValueException.php | 11 +- src/Handler/AbstractHandler.php | 18 - src/Handler/Handler.php | 155 +++-- src/Handler/HandlerInterface.php | 36 -- src/Mapping/Annotation/Uploadable.php | 22 - src/Mapping/Annotation/UploadableProperty.php | 18 - src/Mapping/Attribute/Uploadable.php | 41 ++ src/Mapping/Attribute/UploadableProperty.php | 27 + .../Configuration/UploadableConfiguration.php | 72 ++- src/Mapping/Driver/UploadableDriver.php | 56 +- src/Mapping/Helper/Behaviour.php | 16 +- src/Model/File.php | 13 +- src/Model/FileInterface.php | 11 +- src/Model/FileTrait.php | 9 +- src/Model/ImageTrait.php | 66 ++- src/NamingStrategy/HashingNamingStrategy.php | 15 +- src/NamingStrategy/NamingStrategyFactory.php | 28 +- .../NamingStrategyInterface.php | 17 +- src/NamingStrategy/OriginNamingStrategy.php | 37 +- src/Resources/config/services.php | 39 ++ src/Resources/config/services.yaml | 19 - src/Serializer/Normalizer/FileNormalizer.php | 40 +- src/Storage/AbstractStorage.php | 12 - src/Storage/FileSystemStorage.php | 62 +- src/Storage/S3Storage.php | 121 ++++ src/Storage/StorageInterface.php | 18 +- src/Storage/StorageResolver.php | 46 ++ tests/Fixtures/Entity/NonUploadableEntity.php | 41 ++ tests/Fixtures/Entity/UploadableEntity.php | 57 ++ .../Fixtures/Entity/UploadableKeepEntity.php | 58 ++ .../FileSubscriberLifecycleTest.php | 200 +++++++ tests/Integrational/ServiceWiringTest.php | 52 ++ tests/Integrational/TestKernel.php | 84 +++ .../ChamberOrchestraFileExtensionTest.php | 57 ++ .../DependencyInjection/ConfigurationTest.php | 149 +++++ tests/Unit/Events/EventsTest.php | 85 +++ tests/Unit/Exception/MappingExceptionTest.php | 36 ++ tests/Unit/Handler/HandlerTest.php | 306 ++++++++++ .../Attribute/UploadablePropertyTest.php | 42 ++ .../Unit/Mapping/Attribute/UploadableTest.php | 76 +++ .../UploadableConfigurationTest.php | 105 ++++ .../Mapping/Driver/UploadableDriverTest.php | 160 +++++ tests/Unit/Model/FileTest.php | 49 ++ tests/Unit/Model/ImageTraitTest.php | 97 +++ .../HashingNamingStrategyTest.php | 80 +++ .../NamingStrategyFactoryTest.php | 61 ++ .../OriginNamingStrategyTest.php | 68 +++ .../Normalizer/FileNormalizerTest.php | 104 ++++ tests/Unit/Storage/FileSystemStorageTest.php | 245 ++++++++ tests/Unit/Storage/StorageResolverTest.php | 76 +++ 72 files changed, 4334 insertions(+), 462 deletions(-) create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 src/Entity/ImageTrait.php create mode 100644 src/Entity/RequiredImageTrait.php create mode 100644 src/Events/PostUploadEvent.php create mode 100644 src/Events/PreUploadEvent.php delete mode 100644 src/Handler/AbstractHandler.php delete mode 100644 src/Handler/HandlerInterface.php delete mode 100644 src/Mapping/Annotation/Uploadable.php delete mode 100644 src/Mapping/Annotation/UploadableProperty.php create mode 100644 src/Mapping/Attribute/Uploadable.php create mode 100644 src/Mapping/Attribute/UploadableProperty.php create mode 100644 src/Resources/config/services.php delete mode 100644 src/Resources/config/services.yaml delete mode 100644 src/Storage/AbstractStorage.php create mode 100644 src/Storage/S3Storage.php create mode 100644 src/Storage/StorageResolver.php create mode 100644 tests/Fixtures/Entity/NonUploadableEntity.php create mode 100644 tests/Fixtures/Entity/UploadableEntity.php create mode 100644 tests/Fixtures/Entity/UploadableKeepEntity.php create mode 100644 tests/Integrational/FileSubscriberLifecycleTest.php create mode 100644 tests/Integrational/ServiceWiringTest.php create mode 100644 tests/Integrational/TestKernel.php create mode 100644 tests/Unit/DependencyInjection/ChamberOrchestraFileExtensionTest.php create mode 100644 tests/Unit/DependencyInjection/ConfigurationTest.php create mode 100644 tests/Unit/Events/EventsTest.php create mode 100644 tests/Unit/Exception/MappingExceptionTest.php create mode 100644 tests/Unit/Handler/HandlerTest.php create mode 100644 tests/Unit/Mapping/Attribute/UploadablePropertyTest.php create mode 100644 tests/Unit/Mapping/Attribute/UploadableTest.php create mode 100644 tests/Unit/Mapping/Configuration/UploadableConfigurationTest.php create mode 100644 tests/Unit/Mapping/Driver/UploadableDriverTest.php create mode 100644 tests/Unit/Model/FileTest.php create mode 100644 tests/Unit/Model/ImageTraitTest.php create mode 100644 tests/Unit/NamingStrategy/HashingNamingStrategyTest.php create mode 100644 tests/Unit/NamingStrategy/NamingStrategyFactoryTest.php create mode 100644 tests/Unit/NamingStrategy/OriginNamingStrategyTest.php create mode 100644 tests/Unit/Serializer/Normalizer/FileNormalizerTest.php create mode 100644 tests/Unit/Storage/FileSystemStorageTest.php create mode 100644 tests/Unit/Storage/StorageResolverTest.php diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9bd7b52..386290d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -37,7 +37,10 @@ "Bash(composer cs-check:*)", "Bash(composer require:*)", "Bash(php -r:*)", - "Bash(php vendor/bin/phpunit:*)" + "Bash(php vendor/bin/phpunit:*)", + "Bash(git mv:*)", + "Bash(./vendor/bin/php-cs-fixer fix:*)", + "Bash(./vendor/bin/phpstan:*)" ] } } diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 9b9d4c6..b1becdc 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -28,6 +28,8 @@ 'scope' => 'all', 'strict' => true, ], + 'nullable_type_declaration_for_default_null_value' => true, + 'nullable_type_declaration' => ['syntax' => 'question_mark'], ]) ->setFinder($finder) ->setRiskyAllowed(true) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cb60b46 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,146 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +ChamberOrchestra File Bundle is a Symfony bundle that automatically handles file uploads for Doctrine ORM entities. It uses PHP attributes to mark uploadable fields, hooks into Doctrine lifecycle events to upload/inject/remove files transparently, and supports pluggable naming strategies and storage backends. + +## Build and Test Commands + +```bash +# Install dependencies +composer install + +# Run all tests +./vendor/bin/phpunit + +# Run specific test file +./vendor/bin/phpunit tests/Unit/Handler/HandlerTest.php + +# Run tests in specific directory +./vendor/bin/phpunit tests/Unit/Storage/ + +# Run single test method +./vendor/bin/phpunit --filter testMethodName + +# Run static analysis (level max) +composer run-script analyse + +# Check code style (dry-run) +composer run-script cs-check + +# Auto-fix code style +./vendor/bin/php-cs-fixer fix +``` + +## Architecture + +### Upload Attributes + +**Uploadable** (`src/Mapping/Attribute/Uploadable.php`): PHP attribute applied to entity classes to mark them as uploadable. Implements Doctrine's `MappingAttribute`. Validates inputs in constructor (prefix must not contain `..`, namingStrategy must implement `NamingStrategyInterface`). Options: `prefix` (upload subdirectory path), `namingStrategy` (class implementing `NamingStrategyInterface`, defaults to `HashingNamingStrategy`), `behaviour` (file removal policy: `Behaviour::Remove`, `Behaviour::Keep`, or `Behaviour::Archive`), `storage` (named storage to use, defaults to `'default'`; must match a name defined under `storages` in bundle config). + +**UploadableProperty** (`src/Mapping/Attribute/UploadableProperty.php`): PHP attribute applied to entity properties of type `File|null` to mark them as upload fields. Options: `mappedBy` (name of the string property that persists the relative file path). + +### Mapping Layer + +**UploadableDriver** (`src/Mapping/Driver/UploadableDriver.php`): Extends `AbstractMappingDriver` from metadata-bundle. Reads `#[Uploadable]` from the entity class and `#[UploadableProperty]` from properties, validates that the naming strategy implements `NamingStrategyInterface` and that `mappedBy` target properties exist, then builds bidirectional field mappings (`upload`/`mappedBy` on the file field, `inversedBy` on the path field). Supports Doctrine embeddables by recursively joining embedded configurations with dot-prefixed field names. + +**UploadableConfiguration** (`src/Mapping/Configuration/UploadableConfiguration.php`): Extends `AbstractMetadataConfiguration`. Stores prefix, behaviour, naming strategy class, and storage name. Provides `getUploadableFieldNames()` (fields with `#[UploadableProperty]`) and `getMappedByFieldNames()` (the corresponding persisted path fields). Implements `__serialize`/`__unserialize` for metadata caching. + +**Behaviour** (`src/Mapping/Helper/Behaviour.php`): Int-backed enum with cases `Keep` (0, leave orphan files on disk), `Remove` (1, delete file when entity is updated/deleted), and `Archive` (2, copy file to archive directory before removing from storage). + +### Event Subscriber + +**FileSubscriber** (`src/EventSubscriber/FileSubscriber.php`): Extends `AbstractDoctrineListener` from metadata-bundle, registered as a Doctrine listener for `postLoad`, `preFlush`, `onFlush`, and `postFlush` events. + +- **postLoad**: Calls `Handler::inject()` on each uploadable field — converts stored relative paths into `Model\File` objects with resolved URI. +- **preFlush**: Iterates the identity map, calls `Handler::notify()` on each uploadable field — detects file changes and sets the `mappedBy` path field so Doctrine's UnitOfWork sees the change. +- **onFlush**: Processes scheduled insertions (upload + update path), updates (remove old file + upload new + update path), and deletions (queue file removal/archive). Uses `getScheduledEntityInsertions/Updates/Deletions` filtered by `UploadableConfiguration`. Calls `recomputeSingleEntityChangeSet()` after path updates. +- **postFlush**: Executes deferred file removals from `$pendingRemove` and archives from `$pendingArchive`. Both arrays store `[entityClass, storageName, paths]` tuples. Removals/archives are deferred to postFlush to ensure database changes succeed before files are affected. + +### Handler + +**Handler** (`src/Handler/Handler.php`): Registered as `lazy: true` in the service container. Depends on `StorageResolver`, `EventDispatcherInterface`, and `$archivePath`. Resolves the correct storage per entity via `UploadableConfiguration::getStorage()`. Implements six operations: +- `notify()`: Skips `Model\File` instances (already injected, mappedBy is correct). For other `File` instances, resolves its relative path via `Storage::resolveRelativePath()` and sets it on the `mappedBy` field. Sets `null` if no file. +- `update()`: After upload, reads the file from the `inversedBy` field and sets the relative path on the `mappedBy` field via `getPathname()`. Handles null/missing files. +- `upload()`: Derives entity class via `ClassUtils::getClass()`, dispatches `PreUploadEvent` (with `$entityClass`), creates a `NamingStrategyInterface` instance via `NamingStrategyFactory::create()`, delegates to `Storage::upload()`, wraps the result in a `Model\File` with resolved path and URI, then dispatches `PostUploadEvent` (with `$entityClass`). +- `remove(string $entityClass, string $storageName, ?string $relativePath)`: Resolves path/URI, dispatches `PreRemoveEvent` (with `$entityClass`), calls `Storage::remove()`, dispatches `PostRemoveEvent` (with `$entityClass`). +- `archive(string $storageName, ?string $relativePath)`: Downloads the file from storage to the archive directory via `Storage::download()`, then removes it from storage. Works with both filesystem and S3 backends. +- `inject()`: Reads the relative path from `mappedBy`, resolves to absolute path and URI via Storage, creates a `Model\File` instance and sets it on the file property. + +### Storage + +**StorageInterface** (`src/Storage/StorageInterface.php`): Contract for file operations: `upload`, `remove`, `resolvePath`, `resolveRelativePath`, `resolveUri`, `download`. + +**FileSystemStorage** (`src/Storage/FileSystemStorage.php`): `readonly` local filesystem implementation. Constructed with `uploadPath` (absolute directory) and optional `uriPrefix` (web-accessible path prefix or CDN URL). `upload()` creates the target directory if needed, generates a filename via the naming strategy, validates the filename against path traversal (rejects `/`, `\`, `..`), moves the file to `{uploadPath}/{prefix}/`, and returns the relative path. `download()` copies the file from storage to a local target path. `resolvePath()` prepends the upload root. `resolveUri()` prepends the URI prefix (can be a CDN URL for absolute URIs). + +**S3Storage** (`src/Storage/S3Storage.php`): `readonly` AWS S3 implementation. Constructed with `S3Client`, `bucket`, and optional `uriPrefix`. `upload()` puts the object to S3 and wraps `S3Exception` in a bundle `RuntimeException`. `remove()` catches `S3Exception` and returns `false` for `NoSuchKey`. `download()` uses `S3Client::getObject()` with `SaveAs` to download files to a local path. `resolveUri()` uses `S3Client::getObjectUrl()` when no URI prefix is configured. + +**StorageResolver** (`src/Storage/StorageResolver.php`): Registry of named storages. Each storage defined in the `storages` config is registered by name. The `'default'` alias always points to the configured default storage (explicit via `default_storage` option, or the first enabled storage). Handler uses the resolver to select storage per entity based on `#[Uploadable(storage: '...')]`. + +### Naming Strategies + +**NamingStrategyInterface** (`src/NamingStrategy/NamingStrategyInterface.php`): Contract with a single `name(File $file): string` method. + +**HashingNamingStrategy** (`src/NamingStrategy/HashingNamingStrategy.php`): Default strategy. Generates `md5(originalName + random_bytes) + guessedExtension`. + +**OriginNamingStrategy** (`src/NamingStrategy/OriginNamingStrategy.php`): Preserves the original filename (client name for uploads, basename for regular files). + +**NamingStrategyFactory** (`src/NamingStrategy/NamingStrategyFactory.php`): Static factory with singleton cache (`$factories` array). Validates that the class exists and implements `NamingStrategyInterface`. Provides `reset()` method for clearing the cache in tests. + +### Model + +**File** (`src/Model/File.php`): Extends `Symfony\Component\HttpFoundation\File\File` with a `readonly $uri` property. Implements `FileInterface`. Uses `ImageTrait` for image dimension support. Constructed with `(path, uri)` — passes `checkPath: false` to parent since the file may not exist at injection time. + +**FileInterface** (`src/Model/FileInterface.php`): Contract requiring `getUri(): string|null`. + +### Entity Traits + +**FileTrait** (`src/Entity/FileTrait.php`): Provides `$file` (with `#[UploadableProperty(mappedBy: 'filePath')]`) and `$filePath` (`nullable: true` ORM column). Getter returns `File|null`. + +**ImageTrait** (`src/Entity/ImageTrait.php`): Same pattern with `$image`/`$imagePath` fields mapped via `#[UploadableProperty(mappedBy: 'imagePath')]`. + +**RequiredFileTrait** (`src/Entity/RequiredFileTrait.php`): Like `FileTrait` but `$filePath` column is `nullable: false`, defaults to `''`. + +**RequiredImageTrait** (`src/Entity/RequiredImageTrait.php`): Like `ImageTrait` but `$imagePath` column is `nullable: false`, defaults to `''`. + +### Events + +All events carry `$entityClass` (the fully-qualified entity class name via `ClassUtils::getClass()`), enabling listeners to filter by entity type. + +**PreUploadEvent** / **PostUploadEvent** (`src/Events/`): Dispatched before/after file upload. Both carry readonly `$entityClass`, `$entity`, `$file`, and `$fieldName`. `PreUploadEvent` provides the original source file (before storage move). `PostUploadEvent` provides the resolved `Model\File` (after storage). Use `PostUploadEvent` for post-processing such as image resizing, thumbnail generation, or metadata extraction. + +**PreRemoveEvent** / **PostRemoveEvent** (`src/Events/`): Dispatched before/after file deletion. Both extend `AbstractEvent` which carries readonly `$entityClass`, `$relativePath`, `$resolvedPath`, and `$resolvedUri`. Subscribe to these to hook into file removal (e.g., clearing CDN cache, removing thumbnails). + +### Serializer + +**FileNormalizer** (`src/Serializer/Normalizer/FileNormalizer.php`): Symfony Serializer normalizer for `Model\File`. Constructed with `$baseUrl` (wired to `%env(APP_URL)%` by the bundle extension). Normalizes to an absolute URL by prepending `$baseUrl` to relative URIs. Absolute URIs (starting with `http://` or `https://`, e.g. from S3 or CDN storage) are returned as-is. Per-storage CDN is configured by setting the storage's `uri_prefix` to the CDN URL. + +### Service Configuration + +Services are autowired and autoconfigured via `src/Resources/config/services.php`. The `Handler` is registered as `lazy: true`. Directories excluded from autowiring: `DependencyInjection`, `Resources`, `ExceptionInterface`, `NamingStrategy`, `Model`, `Mapping`, `Entity`, `Events`, `Storage`. + +Bundle configuration key is `chamber_orchestra_file`. Storages are defined under `storages` as a named map. Each storage has: `enabled` (default `true`), `driver` (`file_system` or `s3`), `path`, `uri_prefix` (null for private storage, or a CDN URL for absolute URIs), and S3-specific `bucket`, `region`, `endpoint`. S3 storages require `bucket` and `region` (validated at the Configuration tree level). The `default_storage` option selects which storage is the default (if omitted, the first enabled storage is used). The `archive_path` option (default `%kernel.project_dir%/var/archive`) sets the directory for `Behaviour::Archive`. The `FileNormalizer` receives `%env(APP_URL)%` as its base URL for resolving relative URIs. Entities select storage via `#[Uploadable(storage: 'name')]`. + +## Code Style + +- PHP 8.5+ with strict types (`declare(strict_types=1);`) +- PSR-4 autoloading: `ChamberOrchestra\FileBundle\` → `src/` +- `@PER-CS` + `@Symfony` PHP-CS-Fixer rulesets +- Native function invocations must be backslash-prefixed (e.g., `\array_merge()`, `\sprintf()`, `\count()`) +- No global namespace imports — never use `use function` or `use const` +- Nullable types use `?` prefix syntax (e.g., `?string` not `string|null`) +- Ordered imports (alpha), no unused imports, single quotes, trailing commas in multiline +- PHPStan level max + +## Dependencies + +- Requires PHP 8.5, Symfony 8.0 components (`dependency-injection`, `config`, `framework-bundle`, `runtime`, `options-resolver`), and `chamber-orchestra/metadata-bundle` 8.0 +- Dev: PHPUnit 13, `symfony/test-pack`, `symfony/mime`, `symfony/serializer`, `aws/aws-sdk-php`, `friendsofphp/php-cs-fixer`, `phpstan/phpstan` +- Suggests: `aws/aws-sdk-php` (for S3 storage driver) +- Main branch is `main` + +## Testing Conventions + +- Use music thematics for test fixtures and naming (e.g., entity names like `Composition`, `Instrument`, `Rehearsal`, `Score`; file names like `symphony_no_5.pdf`, `violin_concerto.mp3`, `moonlight_sonata.jpg`; prefixes like `scores`, `recordings`) diff --git a/README.md b/README.md new file mode 100644 index 0000000..5196a49 --- /dev/null +++ b/README.md @@ -0,0 +1,551 @@ +# ChamberOrchestra File Bundle + +A Symfony bundle for automatic file upload handling on Doctrine ORM entities. Mark your entity with PHP attributes, and the bundle transparently uploads, injects, and removes files through Doctrine lifecycle events. + +Supports local filesystem and Amazon S3 storage backends, multiple named storages, pluggable naming strategies, and Doctrine embeddables. + +## Requirements + +- PHP 8.5+ +- Symfony 8.0 +- Doctrine ORM + +## Installation + +```bash +composer require chamber-orchestra/file-bundle +``` + +For S3 storage support: + +```bash +composer require aws/aws-sdk-php +``` + +## Quick Start + +### 1. Configure the bundle + +```yaml +# config/packages/chamber_orchestra_file.yaml +chamber_orchestra_file: + storages: + default: + driver: file_system + path: '%kernel.project_dir%/public/uploads' + uri_prefix: '/uploads' +``` + +### 2. Add attributes to your entity + +```php +use ChamberOrchestra\FileBundle\Mapping\Attribute\Uploadable; +use ChamberOrchestra\FileBundle\Mapping\Attribute\UploadableProperty; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\HttpFoundation\File\File; + +#[ORM\Entity] +#[Uploadable] +class Composition +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 255)] + private string $title; + + #[UploadableProperty(mappedBy: 'scorePath')] + private ?File $score = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $scorePath = null; + + // getters and setters... + + public function getScore(): ?File + { + return $this->score; + } + + public function setScore(?File $score): void + { + $this->score = $score; + } +} +``` + +### 3. Upload a file + +```php +use Symfony\Component\HttpFoundation\File\UploadedFile; + +$composition = new Composition(); +$composition->setTitle('Symphony No. 5'); +$composition->setScore($uploadedFile); + +$entityManager->persist($composition); +$entityManager->flush(); +``` + +That's it. The bundle handles the rest: +- Moves the file to the configured storage path +- Persists the relative path in `scorePath` +- On subsequent loads, injects a `Model\File` object with the resolved path and URI + +### 4. Access the file + +After loading the entity from the database, the `score` property holds a `ChamberOrchestra\FileBundle\Model\File` instance: + +```php +$composition = $entityManager->find(Composition::class, 1); + +$file = $composition->getScore(); +$file->getUri(); // "/uploads/symphony_no_5_a1b2c3.pdf" +$file->getPathname(); // "/var/www/public/uploads/symphony_no_5_a1b2c3.pdf" +``` + +## Configuration + +The bundle supports multiple named storages. Each storage is defined under the `storages` key and can use different drivers and settings. + +### Local Filesystem + +```yaml +chamber_orchestra_file: + storages: + default: + driver: file_system + path: '%kernel.project_dir%/public/uploads' + uri_prefix: '/uploads' +``` + +### Amazon S3 + +```yaml +chamber_orchestra_file: + storages: + default: + driver: s3 + bucket: my-recordings-bucket + region: us-east-1 + uri_prefix: '/uploads' # optional, uses S3 URLs if omitted + endpoint: 'http://localhost:9000' # optional, for MinIO/localstack +``` + +### Multiple Storages + +Define as many storages as you need. Each entity can select which storage to use via the `#[Uploadable]` attribute: + +```yaml +chamber_orchestra_file: + default_storage: public # optional, first enabled storage is used if omitted + storages: + public: + driver: file_system + path: '%kernel.project_dir%/public/uploads' + uri_prefix: '/uploads' + secure: + driver: file_system + path: '%kernel.project_dir%/var/share' + archive: + driver: s3 + bucket: orchestra-archive + region: eu-west-1 +``` + +When only one storage is defined, it becomes the default automatically. + +### Secure Storage (private files) + +For files that should not be publicly accessible (contracts, invoices, internal documents), define a storage without a `uri_prefix`: + +```yaml +chamber_orchestra_file: + storages: + default: + driver: file_system + path: '%kernel.project_dir%/public/uploads' + uri_prefix: '/uploads' + secure: + driver: file_system + path: '%kernel.project_dir%/var/share' +``` + +Files stored via a storage with no URI prefix have `getUri()` returning `null`. To serve them, use a controller that reads the file and streams the response with appropriate access control: + +```php +#[Uploadable(storage: 'secure', prefix: 'contracts')] +class Contract +{ + #[UploadableProperty(mappedBy: 'documentPath')] + private ?File $document = null; + + #[ORM\Column(nullable: true)] + private ?string $documentPath = null; +} +``` + +### Disabling a Storage + +A storage can be temporarily disabled without removing its configuration: + +```yaml +chamber_orchestra_file: + storages: + default: + driver: file_system + path: '%kernel.project_dir%/public/uploads' + uri_prefix: '/uploads' + staging: + enabled: false + driver: s3 + bucket: staging-uploads + region: us-east-1 +``` + +### Configuration Reference + +| Option | Type | Default | Description | +|---|---|---|---| +| `default_storage` | `string\|null` | `null` | Name of the default storage. If null, the first enabled storage is used | +| `storages` | `map` | required | Named storage definitions (at least one required) | +| `storages.*.enabled` | `bool` | `true` | Whether this storage is active | +| `storages.*.driver` | `string` | `file_system` | Storage driver: `file_system` or `s3` | +| `storages.*.path` | `string` | `%kernel.project_dir%/public/uploads` | Filesystem path (file_system driver) | +| `storages.*.uri_prefix` | `string\|null` | `null` | Public URI prefix. Null means files are not web-accessible | +| `storages.*.bucket` | `string\|null` | `null` | S3 bucket name (required for s3 driver) | +| `storages.*.region` | `string\|null` | `null` | AWS region (required for s3 driver) | +| `storages.*.endpoint` | `string\|null` | `null` | Custom S3 endpoint for MinIO/localstack | +| `archive_path` | `string` | `%kernel.project_dir%/var/archive` | Local directory for archived files (`Behaviour::Archive`) | + +## Entity Attributes + +### `#[Uploadable]` + +Applied to the entity class. Options: + +| Option | Type | Default | Description | +|---|---|---|---| +| `prefix` | `string` | `''` | Subdirectory within the storage path | +| `namingStrategy` | `string` | `HashingNamingStrategy::class` | Class implementing `NamingStrategyInterface` | +| `behaviour` | `Behaviour` | `Behaviour::Remove` | What happens to files on entity update/delete | +| `storage` | `string` | `'default'` | Named storage backend to use | + +```php +use ChamberOrchestra\FileBundle\Mapping\Attribute\Uploadable; +use ChamberOrchestra\FileBundle\Mapping\Helper\Behaviour; +use ChamberOrchestra\FileBundle\NamingStrategy\OriginNamingStrategy; + +#[Uploadable( + prefix: 'scores', + namingStrategy: OriginNamingStrategy::class, + behaviour: Behaviour::Keep, + storage: 'archive', +)] +class Score +{ + // ... +} +``` + +### `#[UploadableProperty]` + +Applied to file properties. Options: + +| Option | Type | Description | +|---|---|---| +| `mappedBy` | `string` | Name of the string property that stores the relative file path | + +The `mappedBy` property must exist on the same class and be a Doctrine-mapped column. + +## Behaviour + +The `Behaviour` enum controls what happens to files when an entity is updated or deleted: + +- `Behaviour::Remove` (default) — old files are deleted from storage after a successful flush +- `Behaviour::Keep` — old files remain in storage (useful for audit trails or versioning) +- `Behaviour::Archive` — old files are moved to a local archive directory before being removed from storage + +### Archiving + +When using `Behaviour::Archive`, files are downloaded from their storage backend (including S3) and saved to a local archive directory before deletion. Configure the archive path: + +```yaml +chamber_orchestra_file: + archive_path: '%kernel.project_dir%/var/archive' # default + storages: + default: + driver: file_system + path: '%kernel.project_dir%/public/uploads' + uri_prefix: '/uploads' +``` + +```php +#[Uploadable(behaviour: Behaviour::Archive, prefix: 'contracts')] +class Contract +{ + // Files are archived to var/archive/contracts/ before removal +} +``` + +## Naming Strategies + +### `HashingNamingStrategy` (default) + +Generates a unique filename using MD5 hash with random bytes, preserving the guessed file extension: + +``` +a1b2c3d4e5f67890abcdef1234567890.pdf +``` + +### `OriginNamingStrategy` + +Preserves the original filename as uploaded by the client. Automatically appends a version suffix (`_1`, `_2`, etc.) when a file with the same name already exists in the target directory: + +``` +moonlight_sonata.pdf +moonlight_sonata_1.pdf # if moonlight_sonata.pdf already exists +moonlight_sonata_2.pdf # if _1 also exists +``` + +### Custom Naming Strategy + +Implement `NamingStrategyInterface`: + +```php +use ChamberOrchestra\FileBundle\NamingStrategy\NamingStrategyInterface; +use Symfony\Component\HttpFoundation\File\File; + +class TimestampNamingStrategy implements NamingStrategyInterface +{ + public function name(File $file, string $targetDir = ''): string + { + return \time() . '_' . \bin2hex(\random_bytes(4)) . '.' . $file->guessExtension(); + } +} +``` + +Then reference it in the attribute: + +```php +#[Uploadable(namingStrategy: TimestampNamingStrategy::class)] +``` + +## Entity Traits + +The bundle provides convenience traits for common file/image patterns: + +```php +use ChamberOrchestra\FileBundle\Entity\FileTrait; +use ChamberOrchestra\FileBundle\Mapping\Attribute\Uploadable; + +#[ORM\Entity] +#[Uploadable(prefix: 'recordings')] +class Recording +{ + use FileTrait; // adds $file + $filePath + getFile() + + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private ?int $id = null; +} +``` + +Available traits: + +| Trait | Properties | Nullable | +|---|---|---| +| `FileTrait` | `$file` / `$filePath` | Yes | +| `RequiredFileTrait` | `$file` / `$filePath` | No | +| `ImageTrait` | `$image` / `$imagePath` | Yes | +| `RequiredImageTrait` | `$image` / `$imagePath` | No | + +## Multiple File Fields + +An entity can have multiple uploadable properties: + +```php +#[ORM\Entity] +#[Uploadable(prefix: 'compositions')] +class Composition +{ + #[UploadableProperty(mappedBy: 'scorePath')] + private ?File $score = null; + + #[ORM\Column(nullable: true)] + private ?string $scorePath = null; + + #[UploadableProperty(mappedBy: 'recordingPath')] + private ?File $recording = null; + + #[ORM\Column(nullable: true)] + private ?string $recordingPath = null; +} +``` + +## Doctrine Embeddables + +The bundle supports uploadable fields inside Doctrine embeddables: + +```php +#[ORM\Embeddable] +#[Uploadable(prefix: 'media')] +class MediaEmbed +{ + #[UploadableProperty(mappedBy: 'coverPath')] + private ?File $cover = null; + + #[ORM\Column(nullable: true)] + private ?string $coverPath = null; +} + +#[ORM\Entity] +#[Uploadable] +class Album +{ + #[ORM\Embedded(class: MediaEmbed::class)] + private MediaEmbed $media; +} +``` + +## Image Support + +`Model\File` includes `ImageTrait` with image-specific helpers: + +```php +$image = $album->getCover(); + +if ($image->isImage()) { + $image->getWidth(); // 1920 + $image->getHeight(); // 1080 + $image->getRatio(); // 1.78 + $image->getOrientation(); // EXIF orientation value + $image->getOrientationAngle(); // 90, 180, -90, or 0 + $image->getMetadata(); // Full EXIF data array +} +``` + +## Events + +The bundle dispatches events around file upload and removal, allowing you to hook in for image processing, cache clearing, thumbnail cleanup, CDN invalidation, etc. + +All events carry `$entityClass` — the FQCN of the entity that triggered the event. This allows listeners to target specific entity types. + +| Event | Dispatched | +|---|---| +| `PreUploadEvent` | Before a file is uploaded to storage | +| `PostUploadEvent` | After a file is uploaded to storage | +| `PreRemoveEvent` | Before a file is deleted from storage | +| `PostRemoveEvent` | After a file is deleted from storage | + +### Upload Events + +Upload events carry `$entityClass`, `$entity`, `$file`, and `$fieldName`. Use `PostUploadEvent` for post-processing like image resizing: + +```php +use ChamberOrchestra\FileBundle\Events\PostUploadEvent; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +#[AsEventListener] +class ImageResizeListener +{ + public function __invoke(PostUploadEvent $event): void + { + // Only process images for specific entity types + if (Album::class !== $event->entityClass) { + return; + } + + if ($event->file->isImage()) { + $this->resizer->resize($event->file->getPathname(), 1920, 1080); + } + } +} +``` + +### Removal Events + +Removal events carry `$entityClass`, `$relativePath`, `$resolvedPath`, and `$resolvedUri`: + +```php +use ChamberOrchestra\FileBundle\Events\PreRemoveEvent; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +#[AsEventListener] +class ThumbnailCleanupListener +{ + public function __invoke(PreRemoveEvent $event): void + { + // Clear thumbnails for the file being removed + $this->thumbnailService->purge($event->relativePath); + } +} +``` + +## Serializer Integration + +The bundle includes a Symfony Serializer normalizer for `Model\File` that outputs an absolute URL. + +The normalizer uses the `APP_URL` environment variable as the base URL: + +```php +$serializer->normalize($composition); +// "score" => "https://example.com/uploads/scores/a1b2c3.pdf" +``` + +When a file's URI is already an absolute URL (common with S3 or CDN storages), the normalizer returns it as-is without prepending the base: + +```php +// S3 storage with no uri_prefix — URI is already absolute +$serializer->normalize($recording); +// "score" => "https://my-bucket.s3.amazonaws.com/scores/a1b2c3.pdf" + +// Storage with CDN uri_prefix — URI is already absolute +// uri_prefix: 'https://cdn.example.com/uploads' +$serializer->normalize($recording); +// "score" => "https://cdn.example.com/uploads/scores/a1b2c3.pdf" +``` + +### CDN Support + +To serve files through a CDN, set the storage's `uri_prefix` to the CDN base URL: + +```yaml +chamber_orchestra_file: + storages: + default: + driver: file_system + path: '%kernel.project_dir%/public/uploads' + uri_prefix: 'https://cdn.example.com/uploads' +``` + +Files will be injected with the CDN URL as their URI, and the serializer will pass it through unchanged. + +## Security + +The default storage path (`%kernel.project_dir%/public/uploads`) is inside the web root. If your web server is configured to execute scripts (PHP, Python, etc.) from that directory, an uploaded file could be executed via HTTP. + +**Disable script execution** in your upload directories: + +Nginx: + +```nginx +location /uploads { + location ~ \.(php|phtml|php[0-9])$ { + deny all; + } +} +``` + +Apache (`.htaccess` in the uploads directory): + +```apache + + Require all denied + +``` + +Alternatively, store files **outside the web root** by using a storage path like `%kernel.project_dir%/var/files` with no `uri_prefix`, and serve them through a controller with access control. + +## License + +MIT License. See [LICENSE](LICENSE) for details. diff --git a/composer.json b/composer.json index 67a6e85..5c4a69f 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,76 @@ { - "name": "dev/form-bundle", - "type": "library", - "description": "", - "keywords": [], - "homepage": "", + "name": "chamber-orchestra/file-bundle", + "type": "symfony-bundle", + "description": "Symfony bundle for automatic file upload handling on Doctrine ORM entities", + "keywords": [ + "symfony", + "symfony-bundle", + "file", + "upload", + "doctrine", + "orm", + "storage", + "s3" + ], + "homepage": "https://github.com/chamber-orchestra/file-bundle", "license": "MIT", - "authors": [], + "authors": [ + { + "name": "Andrew Lukin", + "email": "lukin.andrej@gmail.com", + "homepage": "https://github.com/wtorsi", + "role": "Developer" + } + ], "require": { - "php": "^8.3" + "php": "^8.5", + "symfony/dependency-injection": "8.0.*", + "symfony/config": "8.0.*", + "symfony/framework-bundle": "8.0.*", + "symfony/runtime": "8.0.*", + "symfony/options-resolver": "8.0.*", + "chamber-orchestra/metadata-bundle": "8.0.*" + }, + "require-dev": { + "phpunit/phpunit": "^13.0", + "symfony/test-pack": "^1.2", + "symfony/mime": "^8.0", + "symfony/serializer": "^8.0", + "aws/aws-sdk-php": "^3.0", + "friendsofphp/php-cs-fixer": "^3.94", + "phpstan/phpstan": "^2.1" + }, + "suggest": { + "aws/aws-sdk-php": "Required for S3 storage driver (^3.0)" }, "autoload": { "psr-4": { - "Dev\\FormBundle\\": "" + "ChamberOrchestra\\FileBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "minimum-stability": "stable", + "conflict": { + "symfony/symfony": "*" + }, + "extra": { + "symfony": { + "allow-contrib": false, + "require": "8.0.*" + } + }, + "config": { + "allow-plugins": { + "symfony/runtime": true } }, - "minimum-stability": "dev" + "scripts": { + "test": "vendor/bin/phpunit", + "analyse": "vendor/bin/phpstan analyse", + "cs-check": "vendor/bin/php-cs-fixer fix --dry-run --diff" + } } diff --git a/src/ChamberOrchestraFileBundle.php b/src/ChamberOrchestraFileBundle.php index e8a7379..dd6d986 100644 --- a/src/ChamberOrchestraFileBundle.php +++ b/src/ChamberOrchestraFileBundle.php @@ -2,10 +2,17 @@ declare(strict_types=1); -namespace Dev\FileBundle; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ChamberOrchestra\FileBundle; use Symfony\Component\HttpKernel\Bundle\Bundle; -class DevFileBundle extends Bundle +final class ChamberOrchestraFileBundle extends Bundle { } diff --git a/src/DependencyInjection/ChamberOrchestraFileExtension.php b/src/DependencyInjection/ChamberOrchestraFileExtension.php index 0f2dd7a..cb86595 100644 --- a/src/DependencyInjection/ChamberOrchestraFileExtension.php +++ b/src/DependencyInjection/ChamberOrchestraFileExtension.php @@ -2,30 +2,127 @@ declare(strict_types=1); -namespace Dev\FileBundle\DependencyInjection; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ -use Dev\FileBundle\Handler\Handler; +namespace ChamberOrchestra\FileBundle\DependencyInjection; + +use Aws\S3\S3Client; +use ChamberOrchestra\FileBundle\Handler\Handler; +use ChamberOrchestra\FileBundle\Serializer\Normalizer\FileNormalizer; +use ChamberOrchestra\FileBundle\Storage\FileSystemStorage; +use ChamberOrchestra\FileBundle\Storage\S3Storage; +use ChamberOrchestra\FileBundle\Storage\StorageResolver; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; -use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; -class DevFileExtension extends ConfigurableExtension +final class ChamberOrchestraFileExtension extends ConfigurableExtension { + /** + * @param array $configs + */ public function loadInternal(array $configs, ContainerBuilder $container): void { - $container->setAlias('dev_file.storage', 'dev_file.storage.'.$configs['storage']['driver']); - $container->setParameter('dev_file.storage.uri_prefix', $configs['storage']['uri_prefix']); - $container->setParameter('dev_file.storage.path', $configs['storage']['path']); + $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); + $loader->load('services.php'); + + /** @var array $storages */ + $storages = $configs['storages'] ?? []; + $enabledStorages = \array_filter($storages, static fn (array $s): bool => $s['enabled'] ?? true); + + if ([] === $enabledStorages) { + throw new \LogicException('At least one storage must be defined under "chamber_orchestra_file.storages".'); + } + + /** @var string $defaultName */ + $defaultName = $configs['default_storage'] ?? \array_key_first($enabledStorages); + + if (!isset($enabledStorages[$defaultName])) { + throw new \LogicException(\sprintf('Default storage "%s" is not defined or not enabled. Available storages: %s.', $defaultName, \implode(', ', \array_keys($enabledStorages)))); + } + + $resolver = $container->getDefinition(StorageResolver::class); + + foreach ($enabledStorages as $name => $storage) { + $serviceId = 'chamber_orchestra_file.storage.'.$name; + + match ($storage['driver']) { + 'file_system' => $this->registerFileSystemStorage($container, $serviceId, $storage), + 's3' => $this->registerS3Storage($container, $serviceId, $name, $storage), + default => throw new \LogicException(\sprintf('Unsupported storage driver "%s".', $storage['driver'])), + }; + + $resolver->addMethodCall('add', [$name, new Reference($serviceId)]); + } + + // Register the default storage as 'default' if it has a different name + if ('default' !== $defaultName) { + $resolver->addMethodCall('add', ['default', new Reference('chamber_orchestra_file.storage.'.$defaultName)]); + } - $this->loadServicesFiles($container, $configs); + /** @var string $archivePath */ + $archivePath = $configs['archive_path'] ?? '%kernel.project_dir%/var/archive'; + + $container->getDefinition(Handler::class) + ->setArgument('$archivePath', $archivePath); + + if ($container->hasDefinition(FileNormalizer::class)) { + $container->getDefinition(FileNormalizer::class) + ->setArgument('$baseUrl', '%env(APP_URL)%'); + } + } + + /** + * @param array{path: string, uri_prefix: string|null} $storage + */ + private function registerFileSystemStorage(ContainerBuilder $container, string $serviceId, array $storage): void + { + $definition = new Definition(FileSystemStorage::class); + $definition->setArguments([ + $storage['path'], + $storage['uri_prefix'], + ]); + $container->setDefinition($serviceId, $definition); } - private function loadServicesFiles(ContainerBuilder $container, array $configs): void + /** + * @param array{driver: string, path: string, uri_prefix: string|null, enabled?: bool, bucket?: string|null, region?: string|null, endpoint?: string|null} $storage + */ + private function registerS3Storage(ContainerBuilder $container, string $serviceId, string $name, array $storage): void { - $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); - $loader->load('services.yaml'); + if (!\class_exists(S3Client::class)) { + throw new \LogicException('The "aws/aws-sdk-php" package is required for S3 storage. Install it with "composer require aws/aws-sdk-php".'); + } + + /** @var string $region */ + $region = $storage['region'] ?? 'us-east-1'; + + $clientArgs = [ + 'region' => $region, + 'version' => 'latest', + ]; + + if (null !== ($storage['endpoint'] ?? null)) { + $clientArgs['endpoint'] = $storage['endpoint']; + } + + $clientDefinition = new Definition(S3Client::class, [$clientArgs]); + $container->setDefinition($serviceId.'.client', $clientDefinition); + + $s3Definition = new Definition(S3Storage::class); + $s3Definition->setArguments([ + $clientDefinition, + $storage['bucket'] ?? '', + $storage['uri_prefix'], + ]); + $container->setDefinition($serviceId, $s3Definition); } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 7a4facb..824f4e4 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -2,66 +2,79 @@ declare(strict_types=1); -namespace Dev\FileBundle\DependencyInjection; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ChamberOrchestra\FileBundle\DependencyInjection; -use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; -class Configuration implements ConfigurationInterface +final class Configuration implements ConfigurationInterface { - protected $supportedDbDrivers = ['orm']; - protected $supportedStorage = ['file_system']; + private const array SUPPORTED_STORAGE_DRIVERS = ['file_system', 's3']; - public function getConfigTreeBuilder() : TreeBuilder + public function getConfigTreeBuilder(): TreeBuilder { - $tb = new TreeBuilder('dev_file'); - $root = $tb->getRootNode(); - $this->addGeneralSection($root); - - return $tb; - } + $tb = new TreeBuilder('chamber_orchestra_file'); - protected function addGeneralSection(ArrayNodeDefinition $node) : void - { - $node + $tb->getRootNode() ->children() - ->scalarNode('db_driver') - ->defaultValue('%dev_file.db_driver%') - ->beforeNormalization() - ->ifString() - ->then(function ($v) { - return strtolower($v); - }) + ->scalarNode('default_storage') + ->defaultNull() + ->info('Name of the default storage. If null, the first defined storage is used.') ->end() - ->validate() - ->ifNotInArray($this->supportedDbDrivers) - ->thenInvalid('The db driver %s is not supported. Please choose one of '.implode(', ', $this->supportedDbDrivers)) + ->scalarNode('archive_path') + ->defaultValue('%kernel.project_dir%/var/archive') + ->info('Directory where files are moved when using Behaviour::Archive.') ->end() - ->end() - ->arrayNode('storage') - ->children() - ->scalarNode('driver')->defaultValue('file_system')->isRequired() - ->beforeNormalization() - ->ifString() - ->then(function ($v) { - return strtolower($v); - }) - ->end() - ->validate() - ->ifNotInArray($this->supportedStorage) - ->thenInvalid('The storage %s is not supported. Please choose one of '.implode(', ', $this->supportedStorage)) + ->arrayNode('storages') + ->useAttributeAsKey('name') + ->requiresAtLeastOneElement() + ->arrayPrototype() + ->canBeEnabled() + ->children() + ->enumNode('driver') + ->values(self::SUPPORTED_STORAGE_DRIVERS) + ->defaultValue('file_system') + ->beforeNormalization() + ->ifString() + ->then(static fn (string $v): string => \strtolower($v)) + ->end() + ->end() + ->scalarNode('path') + ->defaultValue('%kernel.project_dir%/public/uploads') + ->end() + ->scalarNode('uri_prefix') + ->defaultNull() + ->end() + ->scalarNode('bucket') + ->defaultNull() + ->end() + ->scalarNode('region') + ->defaultNull() + ->end() + ->scalarNode('endpoint') + ->defaultNull() + ->end() + ->end() + ->validate() + ->ifTrue(static fn (array $v): bool => 's3' === ($v['driver'] ?? 'file_system') && (null === $v['bucket'] || '' === $v['bucket'])) + ->thenInvalid('The "bucket" option is required when driver is "s3".') + ->end() + ->validate() + ->ifTrue(static fn (array $v): bool => 's3' === ($v['driver'] ?? 'file_system') && (null === $v['region'] || '' === $v['region'])) + ->thenInvalid('The "region" option is required when driver is "s3".') ->end() - ->end() - ->scalarNode('path') - ->isRequired() - ->defaultValue('%kernel.project_dir%/public/uploads') - ->end() - ->scalarNode('uri_prefix') - ->defaultValue('/uploads') ->end() ->end() ->end() - ->end(); + ; + + return $tb; } } diff --git a/src/Entity/FileTrait.php b/src/Entity/FileTrait.php index f7066fc..42fb0da 100644 --- a/src/Entity/FileTrait.php +++ b/src/Entity/FileTrait.php @@ -2,24 +2,31 @@ declare(strict_types=1); -namespace Dev\FileBundle\Entity; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ -use Dev\FileBundle\Mapping\Annotation as Dev; +namespace ChamberOrchestra\FileBundle\Entity; + +use ChamberOrchestra\FileBundle\Mapping\Attribute as Dev; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\HttpFoundation\File\File; trait FileTrait { #[Dev\UploadableProperty(mappedBy: 'filePath')] - protected File|null $file = null; + protected ?File $file = null; #[ORM\Column(type: 'string', length: 255, nullable: true)] - protected string|null $filePath = null; + protected ?string $filePath = null; /** - * @return File|\Dev\FileBundle\Model\File + * @return File|\ChamberOrchestra\FileBundle\Model\File */ - public function getFile(): File|null + public function getFile(): ?File { return $this->file; } -} \ No newline at end of file +} diff --git a/src/Entity/ImageTrait.php b/src/Entity/ImageTrait.php new file mode 100644 index 0000000..759dc45 --- /dev/null +++ b/src/Entity/ImageTrait.php @@ -0,0 +1,32 @@ +image; + } +} diff --git a/src/Entity/RequiredFileTrait.php b/src/Entity/RequiredFileTrait.php index acdb14d..7e39226 100644 --- a/src/Entity/RequiredFileTrait.php +++ b/src/Entity/RequiredFileTrait.php @@ -2,25 +2,31 @@ declare(strict_types=1); -namespace Dev\FileBundle\Entity; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ -use Dev\FileBundle\Mapping\Annotation as Dev; +namespace ChamberOrchestra\FileBundle\Entity; + +use ChamberOrchestra\FileBundle\Mapping\Attribute as Dev; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\HttpFoundation\File\File; -use Symfony\Component\Serializer\Annotation\Groups; trait RequiredFileTrait { #[Dev\UploadableProperty(mappedBy: 'filePath')] - protected File|null $file = null; + protected ?File $file = null; #[ORM\Column(type: 'string', length: 255, nullable: false)] - protected string $filePath; + protected string $filePath = ''; /** - * @return File|\Dev\FileBundle\Model\File + * @return File|\ChamberOrchestra\FileBundle\Model\File */ - public function getFile(): File|null + public function getFile(): ?File { return $this->file; } -} \ No newline at end of file +} diff --git a/src/Entity/RequiredImageTrait.php b/src/Entity/RequiredImageTrait.php new file mode 100644 index 0000000..0cf5ae5 --- /dev/null +++ b/src/Entity/RequiredImageTrait.php @@ -0,0 +1,32 @@ +image; + } +} diff --git a/src/EventSubscriber/FileSubscriber.php b/src/EventSubscriber/FileSubscriber.php index 38f18f2..c17618a 100644 --- a/src/EventSubscriber/FileSubscriber.php +++ b/src/EventSubscriber/FileSubscriber.php @@ -2,16 +2,24 @@ declare(strict_types=1); -namespace Dev\FileBundle\EventSubscriber; - -use Dev\DoctrineExtensionsBundle\Util\ClassUtils; -use Dev\FileBundle\Handler\HandlerInterface; -use Dev\FileBundle\Mapping\Configuration\UploadableConfiguration; -use Dev\FileBundle\Mapping\Helper\Behaviour; -use Dev\MetadataBundle\EventSubscriber\AbstractDoctrineListener; -use Dev\MetadataBundle\Helper\MetadataArgs; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ChamberOrchestra\FileBundle\EventSubscriber; + +use ChamberOrchestra\FileBundle\Handler\Handler; +use ChamberOrchestra\FileBundle\Mapping\Configuration\UploadableConfiguration; +use ChamberOrchestra\FileBundle\Mapping\Helper\Behaviour; +use ChamberOrchestra\MetadataBundle\EventSubscriber\AbstractDoctrineListener; +use ChamberOrchestra\MetadataBundle\Helper\MetadataArgs; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; +use Doctrine\Common\Util\ClassUtils; use Doctrine\ORM\Event\OnFlushEventArgs; +use Doctrine\ORM\Event\PostFlushEventArgs; use Doctrine\ORM\Event\PostLoadEventArgs; use Doctrine\ORM\Event\PreFlushEventArgs; use Doctrine\ORM\Events; @@ -23,9 +31,21 @@ #[AsDoctrineListener(Events::postFlush)] class FileSubscriber extends AbstractDoctrineListener { + /** @var array}> */ private array $pendingRemove = []; + /** @var array}> */ + private array $pendingArchive = []; - public function __construct(private readonly HandlerInterface $handler) + /** + * Caches which entity classes are uploadable to avoid repeated metadata lookups. + * This is the main performance optimization: postLoad and preFlush run for every + * entity, but typically only a few classes are uploadable. + * + * @var array + */ + private array $uploadableClassCache = []; + + public function __construct(private readonly Handler $handler) { } @@ -33,13 +53,22 @@ public function postLoad(PostLoadEventArgs $event): void { $entity = $event->getObject(); $em = $event->getObjectManager(); + $className = ClassUtils::getClass($entity); + + if (false === ($this->uploadableClassCache[$className] ?? null)) { + return; + } + + $metadata = $this->requireReader()->getExtensionMetadata($em, $className); + $config = $metadata->getConfiguration(UploadableConfiguration::class); + if (!$config instanceof UploadableConfiguration) { + $this->uploadableClassCache[$className] = false; - $metadata = $this->reader->getExtensionMetadata($em, ClassUtils::getClass($entity)); - /** @var UploadableConfiguration $config */ - if (null === $config = $metadata->getConfiguration(UploadableConfiguration::class)) { return; } + $this->uploadableClassCache[$className] = true; + foreach ($config->getUploadableFieldNames() as $fieldName) { $this->handler->inject($metadata, $entity, $fieldName); } @@ -50,23 +79,53 @@ public function preFlush(PreFlushEventArgs $args): void $em = $args->getObjectManager(); $uow = $em->getUnitOfWork(); + + foreach ($this->iterateEntities($uow) as $entity) { + $className = ClassUtils::getClass($entity); + + if (false === ($this->uploadableClassCache[$className] ?? null)) { + continue; + } + + $metadata = $this->requireReader()->getExtensionMetadata($em, $className); + $config = $metadata->getConfiguration(UploadableConfiguration::class); + if (!$config instanceof UploadableConfiguration) { + $this->uploadableClassCache[$className] = false; + + continue; + } + + $this->uploadableClassCache[$className] = true; + + foreach ($config->getUploadableFieldNames() as $fieldName) { + $this->handler->notify($metadata, $entity, $fieldName); + } + } + } + + /** + * @return iterable + */ + private function iterateEntities(\Doctrine\ORM\UnitOfWork $uow): iterable + { + $seen = []; + + foreach ($uow->getScheduledEntityInsertions() as $entity) { + $seen[\spl_object_id($entity)] = true; + yield $entity; + } + foreach ($uow->getIdentityMap() as $entities) { foreach ($entities as $entity) { - if ($entity instanceof Proxy && !$entity->__isInitialized()) { - //skip not initialized entities + if (isset($seen[\spl_object_id($entity)])) { continue; } - $metadata = $this->reader->getExtensionMetadata($em, ClassUtils::getClass($entity)); - /** @var UploadableConfiguration $config */ - $config = $metadata->getConfiguration(UploadableConfiguration::class); - if (null === $config) { + if ($entity instanceof Proxy && !$entity->__isInitialized()) { continue; } - foreach ($config->getUploadableFieldNames() as $fieldName) { - $this->handler->notify($metadata, $entity, $fieldName); - } + yield $entity; } } } @@ -91,16 +150,32 @@ public function onFlush(OnFlushEventArgs $args): void } } - /** - * Only process Behaviour::REMOVE and entities which are already has valid metadata. - */ - public function postFlush(): void + public function postFlush(PostFlushEventArgs $args): void { - /** @var $entity object */ - /** @var $fields string[] */ - while ([$entity, $fields] = \array_shift($this->pendingRemove)) { + $pendingRemove = $this->pendingRemove; + $this->pendingRemove = []; + + $pendingArchive = $this->pendingArchive; + $this->pendingArchive = []; + + foreach ($pendingRemove as [$entityClass, $storageName, $fields]) { foreach ($fields as $relativePath) { - $this->handler->remove($entity, $relativePath); + try { + $this->handler->remove($entityClass, $storageName, $relativePath); + } catch (\Throwable) { + // Individual file removal failures must not abort remaining removals. + // The database transaction already succeeded at this point. + } + } + } + + foreach ($pendingArchive as [$entityClass, $storageName, $fields]) { + foreach ($fields as $relativePath) { + try { + $this->handler->archive($storageName, $relativePath); + } catch (\Throwable) { + // Individual archive failures must not abort remaining operations. + } } } } @@ -109,12 +184,13 @@ private function doUpdate(MetadataArgs $args): void { $em = $args->entityManager; $entity = $args->entity; - $config = $args->configuration; $metadata = $args->extensionMetadata; + /** @var UploadableConfiguration $config */ + $config = $args->configuration; + $uow = $em->getUnitOfWork(); $changeSet = $uow->getEntityChangeSet($entity); - // restrict to fields that changed $fields = \array_intersect($config->getMappedByFieldNames(), \array_keys($changeSet)); $class = $em->getClassMetadata(ClassUtils::getClass($entity)); @@ -128,12 +204,13 @@ private function doUpdate(MetadataArgs $args): void private function doUpload(MetadataArgs $args): void { - $em = $args->entityManager; $entity = $args->entity; - $config = $args->configuration; $metadata = $args->extensionMetadata; - $uow = $em->getUnitOfWork(); + /** @var UploadableConfiguration $config */ + $config = $args->configuration; + + $uow = $args->entityManager->getUnitOfWork(); $changeSet = $uow->getEntityChangeSet($entity); $fields = \array_intersect($config->getMappedByFieldNames(), \array_keys($changeSet)); @@ -150,53 +227,73 @@ private function doRemoveChanged(MetadataArgs $args): void { $em = $args->entityManager; $entity = $args->entity; + + /** @var UploadableConfiguration $config */ $config = $args->configuration; + $behaviour = $config->getBehaviour(); - if (Behaviour::REMOVE !== $config->getBehaviour()) { + if (Behaviour::Keep === $behaviour) { return; } $uow = $em->getUnitOfWork(); $changeSet = $uow->getEntityChangeSet($entity); - $fields = \array_intersect($config->getFieldNames(), \array_keys($changeSet)); + $fields = \array_intersect($config->getMappedByFieldNames(), \array_keys($changeSet)); - if (!count($fields)) { + if (!\count($fields)) { return; } - $remove = []; + $paths = []; foreach ($fields as $field) { - [$old,] = $changeSet[$field]; - if (null === $old) { + /** @var array{mixed, mixed} $change */ + $change = $changeSet[$field]; + $old = $change[0]; + if (!\is_string($old)) { continue; } - $remove[$field] = $old; + $paths[$field] = $old; } - $this->pendingRemove[\spl_object_hash($entity)] = [$entity, $remove]; + $entry = [ClassUtils::getClass($entity), $config->getStorage(), $paths]; + + match ($behaviour) { + Behaviour::Remove => $this->pendingRemove[\spl_object_id($entity)] = $entry, + Behaviour::Archive => $this->pendingArchive[\spl_object_id($entity)] = $entry, + }; } private function doRemove(MetadataArgs $args): void { $entity = $args->entity; - $config = $args->configuration; $metadata = $args->extensionMetadata; - if (Behaviour::REMOVE !== $config->getBehaviour()) { + /** @var UploadableConfiguration $config */ + $config = $args->configuration; + $behaviour = $config->getBehaviour(); + + if (Behaviour::Keep === $behaviour) { return; } - $remove = []; + $paths = []; foreach ($config->getUploadableFieldNames() as $field) { $mapping = $config->getMapping($field); - $relativePath = $metadata->getFieldValue($entity, $mapping['mappedBy']); - if (null === $relativePath) { + /** @var string $mappedBy */ + $mappedBy = $mapping['mappedBy']; + $relativePath = $metadata->getFieldValue($entity, $mappedBy); + if (!\is_string($relativePath)) { continue; } - $remove[$field] = $relativePath; + $paths[$field] = $relativePath; } - $this->pendingRemove[\spl_object_hash($entity)] = [$entity, $remove]; + $entry = [ClassUtils::getClass($entity), $config->getStorage(), $paths]; + + match ($behaviour) { + Behaviour::Remove => $this->pendingRemove[\spl_object_id($entity)] = $entry, + Behaviour::Archive => $this->pendingArchive[\spl_object_id($entity)] = $entry, + }; } } diff --git a/src/Events/AbstractEvent.php b/src/Events/AbstractEvent.php index 6a6a8b2..63e89b3 100644 --- a/src/Events/AbstractEvent.php +++ b/src/Events/AbstractEvent.php @@ -2,17 +2,24 @@ declare(strict_types=1); -namespace Dev\FileBundle\Events; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ChamberOrchestra\FileBundle\Events; use Symfony\Contracts\EventDispatcher\Event; abstract class AbstractEvent extends Event { public function __construct( + public readonly string $entityClass, public readonly string $relativePath, public readonly string $resolvedPath, - public readonly string $resolvedUri - ) - { + public readonly ?string $resolvedUri, + ) { } -} \ No newline at end of file +} diff --git a/src/Events/PostRemoveEvent.php b/src/Events/PostRemoveEvent.php index b802d30..8e400b9 100644 --- a/src/Events/PostRemoveEvent.php +++ b/src/Events/PostRemoveEvent.php @@ -2,8 +2,15 @@ declare(strict_types=1); -namespace Dev\FileBundle\Events; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ -class PostRemoveEvent extends AbstractEvent +namespace ChamberOrchestra\FileBundle\Events; + +final class PostRemoveEvent extends AbstractEvent { -} \ No newline at end of file +} diff --git a/src/Events/PostUploadEvent.php b/src/Events/PostUploadEvent.php new file mode 100644 index 0000000..73bb68b --- /dev/null +++ b/src/Events/PostUploadEvent.php @@ -0,0 +1,33 @@ +getFieldValue($entity, $fieldName); @@ -22,77 +41,121 @@ public function notify(ExtensionMetadataInterface $metadata, object $entity, str return; } + // File was injected by us — the mappedBy field is already correct + if ($file instanceof File) { + return; + } + $config = $this->getConfig($metadata); + $storage = $this->resolveStorage($config); $mapping = $config->getMapping($fieldName); // if the path will be the same, uow changeSet will be empty - $path = (null !== $file && $file->isFile()) ? $this->storage->resolveRelativePath($file->getRealPath(), $config->getPrefix()) : null; - $metadata->setFieldValue($entity, $mapping['mappedBy'], $path); + /** @var string $mappedBy */ + $mappedBy = $mapping['mappedBy']; + $path = (null !== $file && $file->isFile()) ? $storage->resolveRelativePath($file->getRealPath(), $config->getPrefix()) : null; + $metadata->setFieldValue($entity, $mappedBy, $path); } public function update(ExtensionMetadataInterface $metadata, object $object, string $fieldName): void { $config = $this->getConfig($metadata); + $storage = $this->resolveStorage($config); $mapping = $config->getMapping($fieldName); - /** @var \Symfony\Component\HttpFoundation\File\File $file */ - $file = $metadata->getFieldValue($object, $mapping['inversedBy']); + /** @var string $inversedBy */ + $inversedBy = $mapping['inversedBy']; + $file = $metadata->getFieldValue($object, $inversedBy); - if (null === $file || !$file->isFile()) { + if (!$file instanceof \Symfony\Component\HttpFoundation\File\File) { $metadata->setFieldValue($object, $fieldName, null); return; } - if (!$file instanceof \Symfony\Component\HttpFoundation\File\File) { - throw new RuntimeException(sprintf("The file is not instance of '%s'. Not internal usage", File::class)); - } - - $relativePath = $this->storage->resolveRelativePath($file->getRealPath(), $config->getPrefix()); + $relativePath = $storage->resolveRelativePath($file->getPathname(), $config->getPrefix()); $metadata->setFieldValue($object, $fieldName, $relativePath); } public function upload(ExtensionMetadataInterface $metadata, object $object, string $fieldName): void { $config = $this->getConfig($metadata); + $storage = $this->resolveStorage($config); $mapping = $config->getMapping($fieldName); - /** @var \Symfony\Component\HttpFoundation\File\File $file */ - $file = $metadata->getFieldValue($object, $mapping['inversedBy']); + /** @var string $inversedBy */ + $inversedBy = $mapping['inversedBy']; + $file = $metadata->getFieldValue($object, $inversedBy); if (!$file instanceof \Symfony\Component\HttpFoundation\File\File) { - throw new RuntimeException(sprintf("The uploaded file is not instance of '%s'. Not internal usage", UploadedFile::class)); + throw new RuntimeException(\sprintf("The uploaded file is not an instance of '%s'.", \Symfony\Component\HttpFoundation\File\File::class)); } + $entityClass = ClassUtils::getClass($object); + + $this->dispatcher->dispatch(new PreUploadEvent($entityClass, $object, $file, $fieldName)); + $namingStrategy = NamingStrategyFactory::create($config->getNamingStrategy()); - $relativePath = $this->storage->upload($file, $namingStrategy, $config->getPrefix()); + $relativePath = $storage->upload($file, $namingStrategy, $config->getPrefix()); + + $resolvedPath = $storage->resolvePath($relativePath); + $uri = $storage->resolveUri($relativePath); + + $file = new File($resolvedPath, $uri); + $metadata->setFieldValue($object, $inversedBy, $file); + + $this->dispatcher->dispatch(new PostUploadEvent($entityClass, $object, $file, $fieldName)); + } + + public function remove(string $entityClass, string $storageName, ?string $relativePath): void + { + if (null === $relativePath) { + return; + } - $file = $this->storage->resolvePath($relativePath); - $uri = $this->storage->resolveUri($relativePath); + $storage = $this->storageResolver->get($storageName); - $file = new File($file, $uri); - $metadata->setFieldValue($object, $mapping['inversedBy'], $file); + $resolvedPath = $storage->resolvePath($relativePath); + $resolvedUri = $storage->resolveUri($relativePath); + + $this->dispatcher->dispatch(new PreRemoveEvent($entityClass, $relativePath, $resolvedPath, $resolvedUri)); + $storage->remove($resolvedPath); + $this->dispatcher->dispatch(new PostRemoveEvent($entityClass, $relativePath, $resolvedPath, $resolvedUri)); } - public function remove(object $object, string|null $relativePath): void + public function archive(string $storageName, ?string $relativePath): void { if (null === $relativePath) { return; } - $resolvedPath = $this->storage->resolvePath($relativePath); - $resolvedUri = $this->storage->resolveUri($relativePath); + if (\str_contains($relativePath, '..')) { + throw new RuntimeException(\sprintf('Path traversal detected: "%s" contains "..".', $relativePath)); + } + + $storage = $this->storageResolver->get($storageName); + + $archiveTarget = \rtrim($this->archivePath, '/').'/'.\ltrim($relativePath, '/'); + $archiveDir = \dirname($archiveTarget); + + if (!\is_dir($archiveDir)) { + \mkdir($archiveDir, 0755, true); + } + + $storage->download($relativePath, $archiveTarget); - $this->dispatcher->dispatch(new PreRemoveEvent($relativePath, $resolvedPath, $resolvedUri)); - $this->storage->remove($resolvedPath); - $this->dispatcher->dispatch(new PostRemoveEvent($relativePath, $resolvedPath, $resolvedUri)); + $resolvedPath = $storage->resolvePath($relativePath); + $storage->remove($resolvedPath); } public function inject(ExtensionMetadataInterface $metadata, object $object, string $fieldName): void { $config = $this->getConfig($metadata); + $storage = $this->resolveStorage($config); $mapping = $config->getMapping($fieldName); - /** @var string $relativePath */ - $relativePath = $metadata->getFieldValue($object, $mapping['mappedBy']); + /** @var string $mappedBy */ + $mappedBy = $mapping['mappedBy']; + /** @var string|null $relativePath */ + $relativePath = $metadata->getFieldValue($object, $mappedBy); if (null === $relativePath) { $metadata->setFieldValue($object, $fieldName, null); @@ -100,18 +163,26 @@ public function inject(ExtensionMetadataInterface $metadata, object $object, str return; } - $path = $this->storage->resolvePath($relativePath); - $uri = $this->storage->resolveUri($relativePath); + $path = $storage->resolvePath($relativePath); + $uri = $storage->resolveUri($relativePath); $file = new File($path, $uri); $metadata->setFieldValue($object, $fieldName, $file); } - private function getConfig(ExtensionMetadataInterface $metadata): ?UploadableConfiguration + private function getConfig(ExtensionMetadataInterface $metadata): UploadableConfiguration { - /** @var UploadableConfiguration $config */ $config = $metadata->getConfiguration(UploadableConfiguration::class); + if (!$config instanceof UploadableConfiguration) { + throw new RuntimeException(\sprintf("Expected '%s' configuration, got '%s'.", UploadableConfiguration::class, \get_debug_type($config))); + } + return $config; } + + private function resolveStorage(UploadableConfiguration $config): StorageInterface + { + return $this->storageResolver->get($config->getStorage()); + } } diff --git a/src/Handler/HandlerInterface.php b/src/Handler/HandlerInterface.php deleted file mode 100644 index 2a4156a..0000000 --- a/src/Handler/HandlerInterface.php +++ /dev/null @@ -1,36 +0,0 @@ -prefix, '..')) { + throw new InvalidArgumentException(\sprintf('The prefix "%s" must not contain "..".', $this->prefix)); + } + + if ('' === $this->namingStrategy) { + throw new InvalidArgumentException('The namingStrategy must not be empty.'); + } + + if (!\is_subclass_of($this->namingStrategy, NamingStrategyInterface::class)) { + throw new InvalidArgumentException(\sprintf('The namingStrategy "%s" must implement "%s".', $this->namingStrategy, NamingStrategyInterface::class)); + } + } +} diff --git a/src/Mapping/Attribute/UploadableProperty.php b/src/Mapping/Attribute/UploadableProperty.php new file mode 100644 index 0000000..b1c170e --- /dev/null +++ b/src/Mapping/Attribute/UploadableProperty.php @@ -0,0 +1,27 @@ +mappedBy) { + throw new InvalidArgumentException('The mappedBy property name must not be empty.'); + } + } +} diff --git a/src/Mapping/Configuration/UploadableConfiguration.php b/src/Mapping/Configuration/UploadableConfiguration.php index ea7a9da..63a5137 100644 --- a/src/Mapping/Configuration/UploadableConfiguration.php +++ b/src/Mapping/Configuration/UploadableConfiguration.php @@ -2,26 +2,39 @@ declare(strict_types=1); -namespace Dev\FileBundle\Mapping\Configuration; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ -use Dev\FileBundle\Mapping\Annotation\Uploadable; -use Dev\FileBundle\Mapping\Helper\Behaviour; -use Dev\FileBundle\NamingStrategy\HashingNamingStrategy; -use Dev\MetadataBundle\Mapping\ORM\AbstractMetadataConfiguration; +namespace ChamberOrchestra\FileBundle\Mapping\Configuration; + +use ChamberOrchestra\FileBundle\Mapping\Attribute\Uploadable; +use ChamberOrchestra\FileBundle\Mapping\Helper\Behaviour; +use ChamberOrchestra\FileBundle\NamingStrategy\HashingNamingStrategy; +use ChamberOrchestra\MetadataBundle\Mapping\ORM\AbstractMetadataConfiguration; class UploadableConfiguration extends AbstractMetadataConfiguration { - private string $prefix; - private string $behaviour; - private string $namingStrategy; + private string $prefix = ''; + private Behaviour $behaviour = Behaviour::Remove; + private string $namingStrategy = HashingNamingStrategy::class; + private string $storage = 'default'; + /** @var array|null */ private ?array $uploadableFieldsNames = null; + /** @var array|null */ private ?array $mappedByFieldsNames = null; - public function __construct(?Uploadable $annotation) + public function __construct(?Uploadable $annotation = null) { - $this->prefix = $annotation ? $annotation->prefix : ''; - $this->behaviour = $annotation ? $annotation->behaviour : Behaviour::REMOVE; - $this->namingStrategy = $annotation ? $annotation->namingStrategy : HashingNamingStrategy::class; + if (null !== $annotation) { + $this->prefix = $annotation->prefix; + $this->behaviour = $annotation->behaviour; + $this->namingStrategy = $annotation->namingStrategy; + $this->storage = $annotation->storage; + } } public function getPrefix(): string @@ -29,16 +42,24 @@ public function getPrefix(): string return $this->prefix; } - public function getBehaviour(): string|null + public function getBehaviour(): Behaviour { return $this->behaviour; } - public function getNamingStrategy(): string|null + public function getNamingStrategy(): string { return $this->namingStrategy; } + public function getStorage(): string + { + return $this->storage; + } + + /** + * @return array + */ public function getUploadableFieldNames(): array { if (null === $this->uploadableFieldsNames) { @@ -54,13 +75,18 @@ public function getUploadableFieldNames(): array return $this->uploadableFieldsNames; } + /** + * @return array + */ public function getMappedByFieldNames(): array { if (null === $this->mappedByFieldsNames) { $this->mappedByFieldsNames = []; foreach ($this->getUploadableFieldNames() as $fieldName) { $mapping = $this->mappings[$fieldName]; - $this->mappedByFieldsNames[$mapping['mappedBy']] = $mapping['mappedBy']; + /** @var string $mappedBy */ + $mappedBy = $mapping['mappedBy']; + $this->mappedByFieldsNames[$mappedBy] = $mappedBy; } } @@ -73,18 +99,22 @@ public function __serialize(): array 'prefix' => $this->prefix, 'behaviour' => $this->behaviour, 'namingStrategy' => $this->namingStrategy, + 'storage' => $this->storage, 'uploadableFieldsNames' => $this->uploadableFieldsNames, + 'mappedByFieldsNames' => $this->mappedByFieldsNames, ]); } public function __unserialize(array $data): void { parent::__unserialize($data); - [ - 'prefix' => $this->prefix, - 'behaviour' => $this->behaviour, - 'namingStrategy' => $this->namingStrategy, - 'uploadableFieldsNames' => $this->uploadableFieldsNames, - ] = $data; + + /** @var array{mappings: array>, prefix: string, behaviour: Behaviour, namingStrategy: string, storage: string, uploadableFieldsNames: array|null, mappedByFieldsNames: array|null} $data */ + $this->prefix = $data['prefix']; + $this->behaviour = $data['behaviour']; + $this->namingStrategy = $data['namingStrategy']; + $this->storage = $data['storage']; + $this->uploadableFieldsNames = $data['uploadableFieldsNames']; + $this->mappedByFieldsNames = $data['mappedByFieldsNames']; } } diff --git a/src/Mapping/Driver/UploadableDriver.php b/src/Mapping/Driver/UploadableDriver.php index 3f5e2d8..3711e5f 100644 --- a/src/Mapping/Driver/UploadableDriver.php +++ b/src/Mapping/Driver/UploadableDriver.php @@ -2,17 +2,23 @@ declare(strict_types=1); -namespace Dev\FileBundle\Mapping\Driver; - -use Dev\FileBundle\Exception\ORM\MappingException; -use Dev\FileBundle\Mapping\Annotation\Uploadable; -use Dev\FileBundle\Mapping\Annotation\UploadableProperty; -use Dev\FileBundle\Mapping\Configuration\UploadableConfiguration; -use Dev\FileBundle\NamingStrategy\NamingStrategyInterface; -use Dev\MetadataBundle\Mapping\Driver\AbstractMappingDriver; -use Dev\MetadataBundle\Mapping\ExtensionMetadataInterface; -use Dev\MetadataBundle\Mapping\ORM\AbstractMetadataConfiguration; -use Dev\MetadataBundle\Mapping\ORM\ExtensionMetadata; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ChamberOrchestra\FileBundle\Mapping\Driver; + +use ChamberOrchestra\FileBundle\Exception\ORM\MappingException; +use ChamberOrchestra\FileBundle\Mapping\Attribute\Uploadable; +use ChamberOrchestra\FileBundle\Mapping\Attribute\UploadableProperty; +use ChamberOrchestra\FileBundle\Mapping\Configuration\UploadableConfiguration; +use ChamberOrchestra\MetadataBundle\Mapping\Driver\AbstractMappingDriver; +use ChamberOrchestra\MetadataBundle\Mapping\ExtensionMetadataInterface; +use ChamberOrchestra\MetadataBundle\Mapping\ORM\AbstractMetadataConfiguration; +use ChamberOrchestra\MetadataBundle\Mapping\ORM\ExtensionMetadata; class UploadableDriver extends AbstractMappingDriver { @@ -21,18 +27,13 @@ public function loadMetadataForClass(ExtensionMetadataInterface $extensionMetada /** @var ExtensionMetadata $extensionMetadata */ $class = $extensionMetadata->getOriginMetadata()->getReflectionClass(); $className = $extensionMetadata->getName(); - /** @var Uploadable $uploadableClass */ + /** @var Uploadable|null $uploadableClass */ $uploadableClass = $this->reader->getClassAttribute($class, Uploadable::class); - if (null !== $uploadableClass - && !\is_subclass_of($uploadableClass->namingStrategy, NamingStrategyInterface::class)) { - throw MappingException::namingStrategyIsNotValidInstance($className, $uploadableClass->namingStrategy); - } - $config = new UploadableConfiguration($uploadableClass); foreach ($class->getProperties() as $property) { - /** @var UploadableProperty $uploadableField */ + /** @var UploadableProperty|null $uploadableField */ $uploadableField = $this->reader->getPropertyAttribute($property, UploadableProperty::class); if (null === $uploadableField) { continue; @@ -47,7 +48,7 @@ public function loadMetadataForClass(ExtensionMetadataInterface $extensionMetada 'mappedBy' => $uploadableField->mappedBy, ]); - //should be mapped, to add correct access + // should be mapped, to add correct access $config->mapField($uploadableField->mappedBy, [ 'inversedBy' => $name, ]); @@ -57,27 +58,34 @@ public function loadMetadataForClass(ExtensionMetadataInterface $extensionMetada $extensionMetadata->addConfiguration($config); } + /** + * @param list $prefixKeys + */ private function joinEmbeddedConfigurations(AbstractMetadataConfiguration $config, ExtensionMetadataInterface $metadata, array $prefixKeys = [], string $parentFieldName = ''): void { $name = \get_class($config); foreach ($metadata->getEmbeddedMetadataWithConfiguration($name) as $fieldName => $embedded) { $embeddedConfig = $embedded->getConfiguration($name); - $baseFieldName = $parentFieldName . $fieldName; + if (null === $embeddedConfig) { + continue; + } + + $baseFieldName = $parentFieldName.$fieldName; foreach ($embeddedConfig->getMappings() as $embeddedFieldName => $mapping) { foreach ($prefixKeys as $key) { - if (isset($mapping[$key])) { - $mapping[$key] = $baseFieldName . '.' . $mapping[$key]; + if (isset($mapping[$key]) && \is_string($mapping[$key])) { + $mapping[$key] = $baseFieldName.'.'.$mapping[$key]; } } $config->mapEmbeddedField($embedded->getName(), $baseFieldName, $embeddedFieldName, $mapping); } - $this->joinEmbeddedConfigurations($config, $embedded, $prefixKeys, $baseFieldName . '.'); + $this->joinEmbeddedConfigurations($config, $embedded, $prefixKeys, $baseFieldName.'.'); } } - protected function getPropertyAnnotation(): string + protected function getPropertyAttribute(): string { return UploadableProperty::class; } diff --git a/src/Mapping/Helper/Behaviour.php b/src/Mapping/Helper/Behaviour.php index d237a51..13e3666 100644 --- a/src/Mapping/Helper/Behaviour.php +++ b/src/Mapping/Helper/Behaviour.php @@ -2,10 +2,18 @@ declare(strict_types=1); -namespace Dev\FileBundle\Mapping\Helper; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ -enum Behaviour: string +namespace ChamberOrchestra\FileBundle\Mapping\Helper; + +enum Behaviour: int { - public const KEEP = 'KEEP'; - public const REMOVE = 'REMOVE'; + case Keep = 0; + case Remove = 1; + case Archive = 2; } diff --git a/src/Model/File.php b/src/Model/File.php index 48107f6..a70ade8 100644 --- a/src/Model/File.php +++ b/src/Model/File.php @@ -2,18 +2,25 @@ declare(strict_types=1); -namespace Dev\FileBundle\Model; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ChamberOrchestra\FileBundle\Model; class File extends \Symfony\Component\HttpFoundation\File\File implements FileInterface { use ImageTrait; - public function __construct(string $path, public readonly string|null $uri = null) + public function __construct(string $path, public readonly ?string $uri = null) { parent::__construct($path, false); } - public function getUri(): string + public function getUri(): ?string { return $this->uri; } diff --git a/src/Model/FileInterface.php b/src/Model/FileInterface.php index 66e09a9..4930100 100644 --- a/src/Model/FileInterface.php +++ b/src/Model/FileInterface.php @@ -2,9 +2,16 @@ declare(strict_types=1); -namespace Dev\FileBundle\Model; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ChamberOrchestra\FileBundle\Model; interface FileInterface { - public function getUri(): string|null; + public function getUri(): ?string; } diff --git a/src/Model/FileTrait.php b/src/Model/FileTrait.php index 4a50152..082b17b 100644 --- a/src/Model/FileTrait.php +++ b/src/Model/FileTrait.php @@ -2,7 +2,14 @@ declare(strict_types=1); -namespace Dev\FileBundle\Model; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ChamberOrchestra\FileBundle\Model; class FileTrait { diff --git a/src/Model/ImageTrait.php b/src/Model/ImageTrait.php index 5ed4118..71ec561 100644 --- a/src/Model/ImageTrait.php +++ b/src/Model/ImageTrait.php @@ -2,16 +2,25 @@ declare(strict_types=1); -namespace Dev\FileBundle\Model; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ChamberOrchestra\FileBundle\Model; -use Dev\FileBundle\Exception\RuntimeException; +use ChamberOrchestra\FileBundle\Exception\RuntimeException; /** * @mixin File */ trait ImageTrait { + /** @var array|null */ private ?array $imageSize = null; + /** @var array|null */ private ?array $metadata = null; public function isImage(): bool @@ -20,11 +29,13 @@ public function isImage(): bool return false; } - return \str_contains($this->getMimeType(), 'image/'); + $mimeType = $this->getMimeType(); + + return null !== $mimeType && \str_contains($mimeType, 'image/'); } /** - * @return array + * @return array */ public function getImageSize(): array { @@ -34,7 +45,7 @@ public function getImageSize(): array if (null === $this->imageSize) { $size = @\getimagesize($this->getRealPath()); - if (empty($size) || (0 === $size[0]) || (0 === $size[1])) { + if (false === $size || 0 === $size[0] || 0 === $size[1]) { throw new RuntimeException("Can't get image sizes"); } $this->imageSize = $size; @@ -43,38 +54,40 @@ public function getImageSize(): array return $this->imageSize; } - /** - * @return int - */ public function getWidth(): int { - return $this->getImageSize()[0]; + $size = $this->getImageSize(); + /** @var int $width */ + $width = $size[0]; + + return $width; } - /** - * @return int - */ public function getHeight(): int { - return $this->getImageSize()[1]; + $size = $this->getImageSize(); + /** @var int $height */ + $height = $size[1]; + + return $height; } - /** - * @return float - */ public function getRatio(): float { return (float) $this->getWidth() / $this->getHeight(); } - /** - * @return int - */ public function getOrientation(): int { - return $this->getMetadata()['Orientation'] ?? 0; + $metadata = $this->getMetadata(); + $orientation = $metadata['Orientation'] ?? 0; + + return \is_int($orientation) ? $orientation : 0; } + /** + * @return array + */ public function getMetadata(): array { if (!$this->isImage()) { @@ -82,9 +95,12 @@ public function getMetadata(): array } if (null === $this->metadata) { + /** @var array $data */ $data = []; if (\function_exists('exif_read_data')) { - $data = @\exif_read_data($this->getRealPath()); + /** @var array|false $exif */ + $exif = @\exif_read_data($this->getRealPath()); + $data = false !== $exif ? $exif : []; } $this->metadata = $data; } @@ -114,12 +130,4 @@ private function calculateRotation(int $orientation): int default => 0, }; } - - private function isFlipped(int $orientation): bool - { - return match ($orientation) { - 2, 4, 5, 7 => true, - default => false, - }; - } } diff --git a/src/NamingStrategy/HashingNamingStrategy.php b/src/NamingStrategy/HashingNamingStrategy.php index 9a5474b..a474871 100644 --- a/src/NamingStrategy/HashingNamingStrategy.php +++ b/src/NamingStrategy/HashingNamingStrategy.php @@ -2,15 +2,24 @@ declare(strict_types=1); -namespace Dev\FileBundle\NamingStrategy; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ChamberOrchestra\FileBundle\NamingStrategy; use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\UploadedFile; class HashingNamingStrategy implements NamingStrategyInterface { - public function name(File $file): string + public function name(File $file, string $targetDir = ''): string { - return \md5(($file instanceof UploadedFile ? $file->getClientOriginalName() : $file->getBasename()).\time()).'.'.$file->guessExtension(); + $originalName = $file instanceof UploadedFile ? $file->getClientOriginalName() : $file->getBasename(); + + return \md5($originalName.\bin2hex(\random_bytes(8))).'.'.$file->guessExtension(); } } diff --git a/src/NamingStrategy/NamingStrategyFactory.php b/src/NamingStrategy/NamingStrategyFactory.php index 27c99f3..35a0140 100644 --- a/src/NamingStrategy/NamingStrategyFactory.php +++ b/src/NamingStrategy/NamingStrategyFactory.php @@ -2,12 +2,20 @@ declare(strict_types=1); -namespace Dev\FileBundle\NamingStrategy; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ -use Dev\FileBundle\Exception\InvalidArgumentException; +namespace ChamberOrchestra\FileBundle\NamingStrategy; + +use ChamberOrchestra\FileBundle\Exception\InvalidArgumentException; class NamingStrategyFactory { + /** @var array */ private static array $factories = []; public static function create(string $class): NamingStrategyInterface @@ -21,13 +29,17 @@ public static function create(string $class): NamingStrategyInterface } if (!\is_subclass_of($class, NamingStrategyInterface::class)) { - throw new InvalidArgumentException(\sprintf( - "Naming Strategy class '%s' must implement '%s'", - $class, - NamingStrategyInterface::class - )); + throw new InvalidArgumentException(\sprintf("Naming Strategy class '%s' must implement '%s'", $class, NamingStrategyInterface::class)); } - return self::$factories[$class] = new $class(); + /** @var NamingStrategyInterface $strategy */ + $strategy = new $class(); + + return self::$factories[$class] = $strategy; + } + + public static function reset(): void + { + self::$factories = []; } } diff --git a/src/NamingStrategy/NamingStrategyInterface.php b/src/NamingStrategy/NamingStrategyInterface.php index 803e6d6..324ab5e 100644 --- a/src/NamingStrategy/NamingStrategyInterface.php +++ b/src/NamingStrategy/NamingStrategyInterface.php @@ -2,18 +2,23 @@ declare(strict_types=1); -namespace Dev\FileBundle\NamingStrategy; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ChamberOrchestra\FileBundle\NamingStrategy; use Symfony\Component\HttpFoundation\File\File; -use Symfony\Component\HttpFoundation\File\UploadedFile; -/** - * NamingStrategy. - */ interface NamingStrategyInterface { /** * Creates a name for the file being uploaded. + * + * @param string $targetDir Resolved target directory (empty for remote storage) */ - public function name(File $file): string; + public function name(File $file, string $targetDir = ''): string; } diff --git a/src/NamingStrategy/OriginNamingStrategy.php b/src/NamingStrategy/OriginNamingStrategy.php index dd87d1f..61426aa 100644 --- a/src/NamingStrategy/OriginNamingStrategy.php +++ b/src/NamingStrategy/OriginNamingStrategy.php @@ -2,15 +2,46 @@ declare(strict_types=1); -namespace Dev\FileBundle\NamingStrategy; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ChamberOrchestra\FileBundle\NamingStrategy; use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\UploadedFile; class OriginNamingStrategy implements NamingStrategyInterface { - public function name(File $file): string + public function name(File $file, string $targetDir = ''): string { - return ($file instanceof UploadedFile ? $file->getClientOriginalName() : $file->getBasename()); + $name = $file instanceof UploadedFile ? $file->getClientOriginalName() : $file->getBasename(); + $name = \basename($name); + + if ('' === $targetDir || !\is_dir($targetDir)) { + return $name; + } + + if (!\file_exists($targetDir.'/'.$name)) { + return $name; + } + + $extension = \pathinfo($name, \PATHINFO_EXTENSION); + $baseName = '' !== $extension + ? \substr($name, 0, -\strlen($extension) - 1) + : $name; + + $version = 1; + do { + $candidate = '' !== $extension + ? $baseName.'_'.$version.'.'.$extension + : $baseName.'_'.$version; + ++$version; + } while (\file_exists($targetDir.'/'.$candidate)); + + return $candidate; } } diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php new file mode 100644 index 0000000..aa46b59 --- /dev/null +++ b/src/Resources/config/services.php @@ -0,0 +1,39 @@ +services(); + + $services->defaults() + ->autowire() + ->autoconfigure() + ; + + $services->load('ChamberOrchestra\\FileBundle\\', '../../*') + ->exclude([ + '../../DependencyInjection', + '../../Resources', + '../../ExceptionInterface', + '../../NamingStrategy', + '../../Model', + '../../Mapping', + '../../Entity', + '../../Events', + '../../Storage', + ]); + + $services->set(StorageResolver::class); + + $services->set(Handler::class) + ->lazy(); + + $services->set(UploadableDriver::class) + ->autowire() + ->autoconfigure(); +}; diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml deleted file mode 100644 index 89d5b2f..0000000 --- a/src/Resources/config/services.yaml +++ /dev/null @@ -1,19 +0,0 @@ -services: - _defaults: - autowire: true - autoconfigure: true - public: false - - Dev\FileBundle\: - resource: '../../*' - exclude: "../../{DependencyInjection,Resources,ExceptionInterface,NamingStrategy,Model,Mapping,Entity,Events}" - - Dev\FileBundle\Handler\Handler: - lazy: true - - Dev\FileBundle\Storage\FileSystemStorage: - arguments: [ "%dev_file.storage.path%", "%dev_file.storage.uri_prefix%" ] - - Dev\FileBundle\Mapping\Driver\UploadableDriver: - autowire: true - autoconfigure: true \ No newline at end of file diff --git a/src/Serializer/Normalizer/FileNormalizer.php b/src/Serializer/Normalizer/FileNormalizer.php index a901d76..c2d21e3 100644 --- a/src/Serializer/Normalizer/FileNormalizer.php +++ b/src/Serializer/Normalizer/FileNormalizer.php @@ -2,26 +2,39 @@ declare(strict_types=1); -namespace Dev\FileBundle\Serializer\Normalizer; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ -use Dev\FileBundle\Model\File; -use Symfony\Component\HttpFoundation\RequestStack; +namespace ChamberOrchestra\FileBundle\Serializer\Normalizer; + +use ChamberOrchestra\FileBundle\Model\File; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; class FileNormalizer implements NormalizerInterface { - public function __construct(private RequestStack $stack) - { + public function __construct( + private readonly string $baseUrl = '', + ) { } - public function normalize($object, ?string $format = null, array $context = []): string|null + public function normalize($object, ?string $format = null, array $context = []): ?string { - $base = ''; - if ($request = $this->stack->getCurrentRequest()) { - $base = $request->getSchemeAndHttpHost(); + /** @var File $object */ + $uri = $object->getUri(); + + if (null === $uri) { + return null; + } + + if (\str_starts_with($uri, 'http://') || \str_starts_with($uri, 'https://')) { + return $uri; } - return $base.$object?->getUri(); + return \rtrim($this->baseUrl, '/').$uri; } public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool @@ -29,10 +42,13 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return $data instanceof File; } + /** + * @return array + */ public function getSupportedTypes(?string $format): array { return [ - File::class => false, + File::class => true, ]; } -} \ No newline at end of file +} diff --git a/src/Storage/AbstractStorage.php b/src/Storage/AbstractStorage.php deleted file mode 100644 index 8f09b10..0000000 --- a/src/Storage/AbstractStorage.php +++ /dev/null @@ -1,12 +0,0 @@ -uploadPath = \rtrim($uploadPath, '/'); - $this->uriPrefix = $uriPrefix !== null ? '/'.\trim($uriPrefix, '/') : null; + $this->uriPrefix = null !== $uriPrefix ? '/'.\trim($uriPrefix, '/') : null; } public function upload(File $file, NamingStrategyInterface $namingStrategy, string $prefix = ''): string @@ -29,7 +31,16 @@ public function upload(File $file, NamingStrategyInterface $namingStrategy, stri $prefix = '' !== $prefix ? '/'.\trim($prefix, '/') : ''; $uploadPath = $this->resolvePath($prefix); - $name = $namingStrategy->name($file); + if (!\is_dir($uploadPath)) { + \mkdir($uploadPath, 0755, true); + } + + $name = $namingStrategy->name($file, $uploadPath); + + if (\str_contains($name, '/') || \str_contains($name, '\\') || \str_contains($name, '..')) { + throw new RuntimeException(\sprintf('Invalid filename "%s" returned by naming strategy "%s". Filenames must not contain directory separators or "..".', $name, $namingStrategy::class)); + } + $file->move($uploadPath, $name); return $prefix.'/'.$name; @@ -42,10 +53,14 @@ public function remove(string $resolvedPath): bool public function resolvePath(string $path): string { + if (\str_contains($path, '..')) { + throw new RuntimeException(\sprintf('Path traversal detected: "%s" contains "..".', $path)); + } + return $this->uploadPath.$path; } - public function resolveUri(string $path): string|null + public function resolveUri(string $path): ?string { if (null === $this->uriPrefix) { return null; @@ -56,6 +71,23 @@ public function resolveUri(string $path): string|null public function resolveRelativePath(string $path, string $prefix = ''): string { - return \str_replace($this->uploadPath, '', $path); + if (\str_starts_with($path, $this->uploadPath)) { + return \substr($path, \strlen($this->uploadPath)); + } + + return $path; + } + + public function download(string $relativePath, string $targetPath): void + { + $sourcePath = $this->resolvePath($relativePath); + + if (!\is_file($sourcePath)) { + throw new RuntimeException(\sprintf('Cannot download file: "%s" does not exist.', $sourcePath)); + } + + if (!\copy($sourcePath, $targetPath)) { + throw new RuntimeException(\sprintf('Failed to copy file from "%s" to "%s".', $sourcePath, $targetPath)); + } } } diff --git a/src/Storage/S3Storage.php b/src/Storage/S3Storage.php new file mode 100644 index 0000000..6154ce1 --- /dev/null +++ b/src/Storage/S3Storage.php @@ -0,0 +1,121 @@ +bucket = $bucket; + $this->uriPrefix = null !== $uriPrefix ? '/'.\trim($uriPrefix, '/') : null; + } + + public function upload(File $file, NamingStrategyInterface $namingStrategy, string $prefix = ''): string + { + $prefix = '' !== $prefix ? '/'.\trim($prefix, '/') : ''; + $name = $namingStrategy->name($file); + $key = \ltrim($prefix.'/'.$name, '/'); + + $params = [ + 'Bucket' => $this->bucket, + 'Key' => $key, + 'SourceFile' => $file->getRealPath(), + ]; + + $mimeType = $file->getMimeType(); + if (null !== $mimeType) { + $params['ContentType'] = $mimeType; + } + + try { + $this->client->putObject($params); + } catch (S3Exception $e) { + throw new RuntimeException(\sprintf('Failed to upload file to S3 bucket "%s" with key "%s": %s', $this->bucket, $key, $e->getMessage()), 0, $e); + } + + return $prefix.'/'.$name; + } + + public function remove(string $resolvedPath): bool + { + try { + $this->client->deleteObject([ + 'Bucket' => $this->bucket, + 'Key' => $resolvedPath, + ]); + } catch (S3Exception $e) { + if ('NoSuchKey' === $e->getAwsErrorCode()) { + return false; + } + + throw $e; + } + + return true; + } + + public function resolvePath(string $path): string + { + if (\str_contains($path, '..')) { + throw new RuntimeException(\sprintf('Path traversal detected: "%s" contains "..".', $path)); + } + + return \ltrim($path, '/'); + } + + public function resolveUri(string $path): ?string + { + if (null === $this->uriPrefix) { + return $this->client->getObjectUrl($this->bucket, \ltrim($path, '/')); + } + + return $this->uriPrefix.$path; + } + + public function resolveRelativePath(string $path, string $prefix = ''): string + { + $prefix = '' !== $prefix ? '/'.\trim($prefix, '/') : ''; + + if ('' !== $prefix && \str_starts_with($path, $prefix)) { + return $path; + } + + return $prefix.'/'.\ltrim(\basename($path), '/'); + } + + public function download(string $relativePath, string $targetPath): void + { + $key = \ltrim($relativePath, '/'); + + try { + $this->client->getObject([ + 'Bucket' => $this->bucket, + 'Key' => $key, + 'SaveAs' => $targetPath, + ]); + } catch (S3Exception $e) { + throw new RuntimeException(\sprintf('Failed to download file from S3 bucket "%s" with key "%s": %s', $this->bucket, $key, $e->getMessage()), 0, $e); + } + } +} diff --git a/src/Storage/StorageInterface.php b/src/Storage/StorageInterface.php index e267a9f..600c91d 100644 --- a/src/Storage/StorageInterface.php +++ b/src/Storage/StorageInterface.php @@ -2,9 +2,16 @@ declare(strict_types=1); -namespace Dev\FileBundle\Storage; +/* + * This file is part of the ChamberOrchestra package. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ChamberOrchestra\FileBundle\Storage; -use Dev\FileBundle\NamingStrategy\NamingStrategyInterface; +use ChamberOrchestra\FileBundle\NamingStrategy\NamingStrategyInterface; use Symfony\Component\HttpFoundation\File\File; /** @@ -39,5 +46,10 @@ public function resolveRelativePath(string $path, string $prefix = ''): string; /** * Resolves the URI for a file based on the specified object. */ - public function resolveUri(string $path): string|null; + public function resolveUri(string $path): ?string; + + /** + * Downloads a file from storage to a local target path. + */ + public function download(string $relativePath, string $targetPath): void; } diff --git a/src/Storage/StorageResolver.php b/src/Storage/StorageResolver.php new file mode 100644 index 0000000..03d6719 --- /dev/null +++ b/src/Storage/StorageResolver.php @@ -0,0 +1,46 @@ + */ + private array $storages = []; + + public function add(string $name, StorageInterface $storage): void + { + if ('' === $name) { + throw new InvalidArgumentException('Storage name must not be empty.'); + } + + $this->storages[$name] = $storage; + } + + public function get(string $name): StorageInterface + { + if (!isset($this->storages[$name])) { + throw new InvalidArgumentException(\sprintf('Storage "%s" is not registered. Available storages: %s.', $name, \implode(', ', \array_keys($this->storages)))); + } + + return $this->storages[$name]; + } +} diff --git a/tests/Fixtures/Entity/NonUploadableEntity.php b/tests/Fixtures/Entity/NonUploadableEntity.php new file mode 100644 index 0000000..54bbb72 --- /dev/null +++ b/tests/Fixtures/Entity/NonUploadableEntity.php @@ -0,0 +1,41 @@ +id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } +} diff --git a/tests/Fixtures/Entity/UploadableEntity.php b/tests/Fixtures/Entity/UploadableEntity.php new file mode 100644 index 0000000..4b83479 --- /dev/null +++ b/tests/Fixtures/Entity/UploadableEntity.php @@ -0,0 +1,57 @@ +id; + } + + public function getFile(): ?File + { + return $this->file; + } + + public function setFile(?File $file): void + { + $this->file = $file; + } + + public function getFilePath(): ?string + { + return $this->filePath; + } + + public function setFilePath(?string $filePath): void + { + $this->filePath = $filePath; + } +} diff --git a/tests/Fixtures/Entity/UploadableKeepEntity.php b/tests/Fixtures/Entity/UploadableKeepEntity.php new file mode 100644 index 0000000..b453891 --- /dev/null +++ b/tests/Fixtures/Entity/UploadableKeepEntity.php @@ -0,0 +1,58 @@ +id; + } + + public function getFile(): ?File + { + return $this->file; + } + + public function setFile(?File $file): void + { + $this->file = $file; + } + + public function getFilePath(): ?string + { + return $this->filePath; + } + + public function setFilePath(?string $filePath): void + { + $this->filePath = $filePath; + } +} diff --git a/tests/Integrational/FileSubscriberLifecycleTest.php b/tests/Integrational/FileSubscriberLifecycleTest.php new file mode 100644 index 0000000..4020664 --- /dev/null +++ b/tests/Integrational/FileSubscriberLifecycleTest.php @@ -0,0 +1,200 @@ +em = $container->get('doctrine.orm.entity_manager'); + $this->uploadPath = \realpath(\sys_get_temp_dir()).'/file_bundle_test'; + + if (!\is_dir($this->uploadPath)) { + \mkdir($this->uploadPath, 0777, true); + } + + $schemaTool = new SchemaTool($this->em); + $classes = $this->em->getMetadataFactory()->getAllMetadata(); + $schemaTool->dropSchema($classes); + $schemaTool->createSchema($classes); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->removeDirectory($this->uploadPath); + } + + public function testPersistUploadsFileAndSetsPath(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'upload'); + \file_put_contents($tmpFile, 'file content'); + + $entity = new UploadableEntity(); + $entity->setFile(new UploadedFile($tmpFile, 'document.txt', 'text/plain', null, true)); + + $this->em->persist($entity); + $this->em->flush(); + + self::assertNotNull($entity->getFilePath()); + self::assertStringStartsWith('/test/', $entity->getFilePath()); + + $uploadedFilePath = $this->uploadPath.$entity->getFilePath(); + self::assertFileExists($uploadedFilePath); + } + + public function testPostLoadInjectsFileObject(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'upload'); + \file_put_contents($tmpFile, 'file content'); + + $entity = new UploadableEntity(); + $entity->setFile(new UploadedFile($tmpFile, 'document.txt', 'text/plain', null, true)); + + $this->em->persist($entity); + $this->em->flush(); + + $id = $entity->getId(); + + $this->em->clear(); + + $loaded = $this->em->find(UploadableEntity::class, $id); + self::assertNotNull($loaded); + + $file = $loaded->getFile(); + self::assertInstanceOf(File::class, $file); + self::assertNotNull($file->getUri()); + self::assertStringStartsWith('/uploads/', $file->getUri()); + } + + public function testUpdateReplacesFileAndRemovesOld(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'upload'); + \file_put_contents($tmpFile, 'original content'); + + $entity = new UploadableEntity(); + $entity->setFile(new UploadedFile($tmpFile, 'original.txt', 'text/plain', null, true)); + + $this->em->persist($entity); + $this->em->flush(); + + $oldPath = $entity->getFilePath(); + $oldFullPath = $this->uploadPath.$oldPath; + self::assertFileExists($oldFullPath); + + $newTmpFile = \tempnam(\sys_get_temp_dir(), 'upload'); + \file_put_contents($newTmpFile, 'new content'); + + $entity->setFile(new UploadedFile($newTmpFile, 'replacement.txt', 'text/plain', null, true)); + $this->em->flush(); + + self::assertNotSame($oldPath, $entity->getFilePath()); + self::assertFileDoesNotExist($oldFullPath); + + $newFullPath = $this->uploadPath.$entity->getFilePath(); + self::assertFileExists($newFullPath); + } + + public function testDeleteEntityRemovesFile(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'upload'); + \file_put_contents($tmpFile, 'content to delete'); + + $entity = new UploadableEntity(); + $entity->setFile(new UploadedFile($tmpFile, 'delete-me.txt', 'text/plain', null, true)); + + $this->em->persist($entity); + $this->em->flush(); + + $filePath = $this->uploadPath.$entity->getFilePath(); + self::assertFileExists($filePath); + + $this->em->remove($entity); + $this->em->flush(); + + self::assertFileDoesNotExist($filePath); + } + + public function testDeleteWithBehaviourKeepPreservesFile(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'upload'); + \file_put_contents($tmpFile, 'keep me'); + + $entity = new UploadableKeepEntity(); + $entity->setFile(new UploadedFile($tmpFile, 'keep-me.txt', 'text/plain', null, true)); + + $this->em->persist($entity); + $this->em->flush(); + + $filePath = $this->uploadPath.$entity->getFilePath(); + self::assertFileExists($filePath); + + $this->em->remove($entity); + $this->em->flush(); + + self::assertFileExists($filePath); + } + + public function testNonUploadableEntityIsIgnored(): void + { + $entity = new NonUploadableEntity(); + $entity->setName('test'); + + $this->em->persist($entity); + $this->em->flush(); + + self::assertNotNull($entity->getId()); + + $this->em->clear(); + + $loaded = $this->em->find(NonUploadableEntity::class, $entity->getId()); + self::assertNotNull($loaded); + self::assertSame('test', $loaded->getName()); + } + + private function removeDirectory(string $dir): void + { + if (!\is_dir($dir)) { + return; + } + + $items = \scandir($dir); + foreach ($items as $item) { + if ('.' === $item || '..' === $item) { + continue; + } + $path = $dir.'/'.$item; + \is_dir($path) ? $this->removeDirectory($path) : \unlink($path); + } + \rmdir($dir); + } +} diff --git a/tests/Integrational/ServiceWiringTest.php b/tests/Integrational/ServiceWiringTest.php new file mode 100644 index 0000000..263d5b0 --- /dev/null +++ b/tests/Integrational/ServiceWiringTest.php @@ -0,0 +1,52 @@ +has(Handler::class)); + self::assertInstanceOf(Handler::class, $container->get(Handler::class)); + } + + public function testStorageResolverExists(): void + { + self::bootKernel(); + $container = self::getContainer(); + + self::assertTrue($container->has(StorageResolver::class)); + self::assertInstanceOf(StorageResolver::class, $container->get(StorageResolver::class)); + } + + public function testUploadableDriverExists(): void + { + self::bootKernel(); + $container = self::getContainer(); + + self::assertTrue($container->has(UploadableDriver::class)); + self::assertInstanceOf(UploadableDriver::class, $container->get(UploadableDriver::class)); + } +} diff --git a/tests/Integrational/TestKernel.php b/tests/Integrational/TestKernel.php new file mode 100644 index 0000000..28d32f9 --- /dev/null +++ b/tests/Integrational/TestKernel.php @@ -0,0 +1,84 @@ +load(function ($container): void { + $container->loadFromExtension('framework', [ + 'test' => true, + 'secret' => 'test', + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + ]); + + $container->loadFromExtension('doctrine', [ + 'dbal' => [ + 'driver' => 'pdo_sqlite', + 'url' => 'sqlite:///:memory:', + ], + 'orm' => [ + 'auto_mapping' => false, + 'mappings' => [ + 'TestFixtures' => [ + 'type' => 'attribute', + 'dir' => __DIR__.'/../Fixtures/Entity', + 'prefix' => 'Tests\Fixtures\Entity', + 'is_bundle' => false, + ], + ], + ], + ]); + + $storagePath = \realpath(\sys_get_temp_dir()).'/file_bundle_test'; + $container->loadFromExtension('chamber_orchestra_file', [ + 'storages' => [ + 'default' => [ + 'driver' => 'file_system', + 'path' => $storagePath, + 'uri_prefix' => '/uploads', + ], + ], + ]); + }); + } + + public function getCacheDir(): string + { + return \sys_get_temp_dir().'/file_bundle_test_cache/'.$this->environment; + } + + public function getLogDir(): string + { + return \sys_get_temp_dir().'/file_bundle_test_logs'; + } +} diff --git a/tests/Unit/DependencyInjection/ChamberOrchestraFileExtensionTest.php b/tests/Unit/DependencyInjection/ChamberOrchestraFileExtensionTest.php new file mode 100644 index 0000000..2dd60d4 --- /dev/null +++ b/tests/Unit/DependencyInjection/ChamberOrchestraFileExtensionTest.php @@ -0,0 +1,57 @@ +container = new ContainerBuilder(); + $this->container->setParameter('kernel.project_dir', '/project'); + + $extension = new ChamberOrchestraFileExtension(); + $extension->load([ + 'chamber_orchestra_file' => [ + 'storages' => [ + 'default' => [ + 'driver' => 'file_system', + 'path' => '/var/uploads', + 'uri_prefix' => '/uploads', + ], + ], + ], + ], $this->container); + } + + public function testLoadRegistersStorageResolver(): void + { + self::assertTrue($this->container->hasDefinition(StorageResolver::class)); + } + + public function testLoadRegistersNamedStorage(): void + { + self::assertTrue($this->container->hasDefinition('chamber_orchestra_file.storage.default')); + } + + public function testLoadRegistersHandler(): void + { + self::assertTrue($this->container->hasDefinition(Handler::class)); + } +} diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..acbc26f --- /dev/null +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,149 @@ +getConfigTreeBuilder(); + + self::assertSame('chamber_orchestra_file', $tree->buildTree()->getName()); + } + + public function testDefaultStorageValues(): void + { + $processor = new Processor(); + $config = $processor->processConfiguration(new Configuration(), [ + ['storages' => ['default' => []]], + ]); + + self::assertSame('file_system', $config['storages']['default']['driver']); + self::assertSame('%kernel.project_dir%/public/uploads', $config['storages']['default']['path']); + self::assertNull($config['storages']['default']['uri_prefix']); + } + + public function testInvalidStorageDriverThrows(): void + { + $this->expectException(InvalidConfigurationException::class); + + $processor = new Processor(); + $processor->processConfiguration(new Configuration(), [ + ['storages' => ['default' => ['driver' => 'ftp']]], + ]); + } + + public function testS3StorageDriverIsValid(): void + { + $processor = new Processor(); + $config = $processor->processConfiguration(new Configuration(), [ + ['storages' => ['media' => ['driver' => 's3', 'bucket' => 'my-bucket', 'region' => 'us-east-1']]], + ]); + + self::assertSame('s3', $config['storages']['media']['driver']); + self::assertSame('my-bucket', $config['storages']['media']['bucket']); + self::assertSame('us-east-1', $config['storages']['media']['region']); + } + + public function testS3WithoutBucketThrows(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('"bucket" option is required'); + + $processor = new Processor(); + $processor->processConfiguration(new Configuration(), [ + ['storages' => ['cdn' => ['driver' => 's3', 'region' => 'us-east-1']]], + ]); + } + + public function testS3WithoutRegionThrows(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('"region" option is required'); + + $processor = new Processor(); + $processor->processConfiguration(new Configuration(), [ + ['storages' => ['cdn' => ['driver' => 's3', 'bucket' => 'my-bucket']]], + ]); + } + + public function testS3EndpointDefaultsToNull(): void + { + $processor = new Processor(); + $config = $processor->processConfiguration(new Configuration(), [ + ['storages' => ['cdn' => ['driver' => 's3', 'bucket' => 'my-bucket', 'region' => 'us-east-1']]], + ]); + + self::assertNull($config['storages']['cdn']['endpoint']); + } + + public function testDriverNormalizesToLowercase(): void + { + $processor = new Processor(); + $config = $processor->processConfiguration(new Configuration(), [ + ['storages' => ['default' => ['driver' => 'FILE_SYSTEM']]], + ]); + + self::assertSame('file_system', $config['storages']['default']['driver']); + } + + public function testMultipleStorages(): void + { + $processor = new Processor(); + $config = $processor->processConfiguration(new Configuration(), [ + ['storages' => [ + 'public' => ['driver' => 'file_system', 'path' => '/public/uploads', 'uri_prefix' => '/uploads'], + 'private' => ['driver' => 'file_system', 'path' => '/var/share'], + ]], + ]); + + self::assertCount(2, $config['storages']); + self::assertSame('/uploads', $config['storages']['public']['uri_prefix']); + self::assertNull($config['storages']['private']['uri_prefix']); + } + + public function testDefaultStorageCanBeSpecified(): void + { + $processor = new Processor(); + $config = $processor->processConfiguration(new Configuration(), [ + [ + 'default_storage' => 'private', + 'storages' => [ + 'public' => ['driver' => 'file_system'], + 'private' => ['driver' => 'file_system', 'path' => '/var/share'], + ], + ], + ]); + + self::assertSame('private', $config['default_storage']); + } + + public function testStorageCanBeDisabled(): void + { + $processor = new Processor(); + $config = $processor->processConfiguration(new Configuration(), [ + ['storages' => [ + 'public' => ['driver' => 'file_system'], + 'staging' => ['enabled' => false], + ]], + ]); + + self::assertTrue($config['storages']['public']['enabled']); + self::assertFalse($config['storages']['staging']['enabled']); + } +} diff --git a/tests/Unit/Events/EventsTest.php b/tests/Unit/Events/EventsTest.php new file mode 100644 index 0000000..c925d81 --- /dev/null +++ b/tests/Unit/Events/EventsTest.php @@ -0,0 +1,85 @@ +entityClass); + self::assertSame('/relative/path.txt', $event->relativePath); + self::assertSame('/resolved/path.txt', $event->resolvedPath); + self::assertSame('/uploads/path.txt', $event->resolvedUri); + } + + public function testPostRemoveEventProperties(): void + { + $event = new PostRemoveEvent('App\\Entity\\Document', '/relative/path.txt', '/resolved/path.txt', '/uploads/path.txt'); + + self::assertSame('App\\Entity\\Document', $event->entityClass); + self::assertSame('/relative/path.txt', $event->relativePath); + self::assertSame('/resolved/path.txt', $event->resolvedPath); + self::assertSame('/uploads/path.txt', $event->resolvedUri); + } + + public function testEventsExtendSymfonyEvent(): void + { + $pre = new PreRemoveEvent('App\\Entity\\Doc', '/a', '/b', '/c'); + $post = new PostRemoveEvent('App\\Entity\\Doc', '/a', '/b', '/c'); + + self::assertInstanceOf(Event::class, $pre); + self::assertInstanceOf(Event::class, $post); + } + + public function testPreUploadEventProperties(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'test'); + \file_put_contents($tmpFile, 'content'); + $file = new UploadedFile($tmpFile, 'requiem.pdf', 'application/pdf', null, true); + $entity = new \stdClass(); + + $event = new PreUploadEvent(\stdClass::class, $entity, $file, 'score'); + + self::assertSame(\stdClass::class, $event->entityClass); + self::assertSame($entity, $event->entity); + self::assertSame($file, $event->file); + self::assertSame('score', $event->fieldName); + self::assertInstanceOf(Event::class, $event); + + @\unlink($tmpFile); + } + + public function testPostUploadEventProperties(): void + { + $file = new File('/uploads/scores/requiem.pdf', '/uploads/scores/requiem.pdf'); + $entity = new \stdClass(); + + $event = new PostUploadEvent(\stdClass::class, $entity, $file, 'score'); + + self::assertSame(\stdClass::class, $event->entityClass); + self::assertSame($entity, $event->entity); + self::assertSame($file, $event->file); + self::assertSame('score', $event->fieldName); + self::assertInstanceOf(Event::class, $event); + } +} diff --git a/tests/Unit/Exception/MappingExceptionTest.php b/tests/Unit/Exception/MappingExceptionTest.php new file mode 100644 index 0000000..8faac1e --- /dev/null +++ b/tests/Unit/Exception/MappingExceptionTest.php @@ -0,0 +1,36 @@ +getMessage()); + self::assertStringContainsString(NamingStrategyInterface::class, $exception->getMessage()); + } + + public function testNoUploadedFieldsMessage(): void + { + $exception = MappingException::noUploadedFields('App\\Entity\\Bar'); + + self::assertStringContainsString('App\\Entity\\Bar', $exception->getMessage()); + self::assertStringContainsString(UploadableProperty::class, $exception->getMessage()); + } +} diff --git a/tests/Unit/Handler/HandlerTest.php b/tests/Unit/Handler/HandlerTest.php new file mode 100644 index 0000000..d88e00e --- /dev/null +++ b/tests/Unit/Handler/HandlerTest.php @@ -0,0 +1,306 @@ +storage = $this->createMock(StorageInterface::class); + $this->dispatcher = $this->createMock(EventDispatcherInterface::class); + $this->metadata = $this->createMock(ExtensionMetadataInterface::class); + + $resolver = new StorageResolver(); + $resolver->add('default', $this->storage); + + $this->handler = new Handler($resolver, $this->dispatcher, \sys_get_temp_dir().'/file_bundle_archive_test'); + + $this->config = new UploadableConfiguration(new Uploadable(prefix: 'test')); + $this->config->mapField('file', ['upload' => true, 'mappedBy' => 'filePath']); + $this->config->mapField('filePath', ['inversedBy' => 'file']); + + $this->metadata->method('getConfiguration') + ->with(UploadableConfiguration::class) + ->willReturn($this->config); + } + + public function testNotifySetsRelativePath(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'test'); + \file_put_contents($tmpFile, 'content'); + $file = new File($tmpFile); + $entity = new \stdClass(); + + $this->metadata->method('getFieldValue') + ->with($entity, 'file') + ->willReturn($file); + + $this->storage->method('resolveRelativePath') + ->willReturn('/test/file.txt'); + + $this->metadata->expects(self::once()) + ->method('setFieldValue') + ->with($entity, 'filePath', '/test/file.txt'); + + $this->handler->notify($this->metadata, $entity, 'file'); + + \unlink($tmpFile); + } + + public function testNotifySetsNullWhenNoFile(): void + { + $entity = new \stdClass(); + + $this->metadata->method('getFieldValue') + ->with($entity, 'file') + ->willReturn(null); + + $this->metadata->expects(self::once()) + ->method('setFieldValue') + ->with($entity, 'filePath', null); + + $this->handler->notify($this->metadata, $entity, 'file'); + } + + public function testNotifyIgnoresNonFileValues(): void + { + $entity = new \stdClass(); + + $this->metadata->method('getFieldValue') + ->with($entity, 'file') + ->willReturn('string-value'); + + $this->metadata->expects(self::never()) + ->method('setFieldValue'); + + $this->handler->notify($this->metadata, $entity, 'file'); + } + + public function testUpdateSetsPathFromInversedBy(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'test'); + \file_put_contents($tmpFile, 'content'); + $file = new File($tmpFile); + $entity = new \stdClass(); + + $this->metadata->method('getFieldValue') + ->with($entity, 'file') + ->willReturn($file); + + $this->storage->method('resolveRelativePath') + ->willReturn('/test/file.txt'); + + $this->metadata->expects(self::once()) + ->method('setFieldValue') + ->with($entity, 'filePath', '/test/file.txt'); + + $this->handler->update($this->metadata, $entity, 'filePath'); + + \unlink($tmpFile); + } + + public function testUpdateSetsNullWhenFileIsNull(): void + { + $entity = new \stdClass(); + + $this->metadata->method('getFieldValue') + ->with($entity, 'file') + ->willReturn(null); + + $this->metadata->expects(self::once()) + ->method('setFieldValue') + ->with($entity, 'filePath', null); + + $this->handler->update($this->metadata, $entity, 'filePath'); + } + + public function testUploadDelegatesToStorageAndCreatesModelFile(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'test'); + \file_put_contents($tmpFile, 'content'); + $file = new UploadedFile($tmpFile, 'original.txt', 'text/plain', null, true); + $entity = new \stdClass(); + + $this->metadata->method('getFieldValue') + ->with($entity, 'file') + ->willReturn($file); + + $this->storage->method('upload') + ->willReturn('/test/hashed.txt'); + + $this->storage->method('resolvePath') + ->with('/test/hashed.txt') + ->willReturn('/uploads/test/hashed.txt'); + + $this->storage->method('resolveUri') + ->with('/test/hashed.txt') + ->willReturn('/uploads/test/hashed.txt'); + + $this->metadata->expects(self::once()) + ->method('setFieldValue') + ->with($entity, 'file', self::isInstanceOf(\ChamberOrchestra\FileBundle\Model\File::class)); + + $this->handler->upload($this->metadata, $entity, 'filePath'); + + @\unlink($tmpFile); + } + + public function testUploadDispatchesPreAndPostUploadEvents(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'test'); + \file_put_contents($tmpFile, 'content'); + $file = new UploadedFile($tmpFile, 'nocturne.pdf', 'application/pdf', null, true); + $entity = new \stdClass(); + + $this->metadata->method('getFieldValue') + ->with($entity, 'file') + ->willReturn($file); + + $this->storage->method('upload')->willReturn('/test/nocturne.pdf'); + $this->storage->method('resolvePath')->willReturn('/uploads/test/nocturne.pdf'); + $this->storage->method('resolveUri')->willReturn('/uploads/test/nocturne.pdf'); + + $dispatched = []; + $this->dispatcher->expects(self::exactly(2)) + ->method('dispatch') + ->willReturnCallback(function (object $event) use (&$dispatched): object { + $dispatched[] = $event; + + return $event; + }); + + $this->handler->upload($this->metadata, $entity, 'filePath'); + + self::assertInstanceOf(PreUploadEvent::class, $dispatched[0]); + self::assertSame(\stdClass::class, $dispatched[0]->entityClass); + self::assertInstanceOf(PostUploadEvent::class, $dispatched[1]); + self::assertSame(\stdClass::class, $dispatched[1]->entityClass); + + @\unlink($tmpFile); + } + + public function testUploadThrowsForNonFileValue(): void + { + $entity = new \stdClass(); + + $this->metadata->method('getFieldValue') + ->with($entity, 'file') + ->willReturn('not-a-file'); + + $this->expectException(RuntimeException::class); + + $this->handler->upload($this->metadata, $entity, 'filePath'); + } + + public function testInjectCreatesModelFile(): void + { + $entity = new \stdClass(); + + $this->metadata->method('getFieldValue') + ->with($entity, 'filePath') + ->willReturn('/test/file.txt'); + + $this->storage->method('resolvePath') + ->with('/test/file.txt') + ->willReturn('/var/uploads/test/file.txt'); + + $this->storage->method('resolveUri') + ->with('/test/file.txt') + ->willReturn('/uploads/test/file.txt'); + + $this->metadata->expects(self::once()) + ->method('setFieldValue') + ->with($entity, 'file', self::isInstanceOf(\ChamberOrchestra\FileBundle\Model\File::class)); + + $this->handler->inject($this->metadata, $entity, 'file'); + } + + public function testInjectSetsNullWhenNoPath(): void + { + $entity = new \stdClass(); + + $this->metadata->method('getFieldValue') + ->with($entity, 'filePath') + ->willReturn(null); + + $this->metadata->expects(self::once()) + ->method('setFieldValue') + ->with($entity, 'file', null); + + $this->handler->inject($this->metadata, $entity, 'file'); + } + + public function testRemoveDispatchesEventsAndCallsStorage(): void + { + $this->storage->method('resolvePath') + ->with('/test/file.txt') + ->willReturn('/var/uploads/test/file.txt'); + + $this->storage->method('resolveUri') + ->with('/test/file.txt') + ->willReturn('/uploads/test/file.txt'); + + $dispatched = []; + $this->dispatcher->expects(self::exactly(2)) + ->method('dispatch') + ->willReturnCallback(function (object $event) use (&$dispatched): object { + $dispatched[] = $event; + + return $event; + }); + + $this->storage->expects(self::once()) + ->method('remove') + ->with('/var/uploads/test/file.txt'); + + $this->handler->remove(\stdClass::class, 'default', '/test/file.txt'); + + self::assertInstanceOf(PreRemoveEvent::class, $dispatched[0]); + self::assertSame(\stdClass::class, $dispatched[0]->entityClass); + self::assertInstanceOf(PostRemoveEvent::class, $dispatched[1]); + self::assertSame(\stdClass::class, $dispatched[1]->entityClass); + } + + public function testRemoveReturnsEarlyForNullPath(): void + { + $this->dispatcher->expects(self::never()) + ->method('dispatch'); + + $this->storage->expects(self::never()) + ->method('remove'); + + $this->handler->remove(\stdClass::class, 'default', null); + } +} diff --git a/tests/Unit/Mapping/Attribute/UploadablePropertyTest.php b/tests/Unit/Mapping/Attribute/UploadablePropertyTest.php new file mode 100644 index 0000000..484b970 --- /dev/null +++ b/tests/Unit/Mapping/Attribute/UploadablePropertyTest.php @@ -0,0 +1,42 @@ +mappedBy); + } + + public function testImplementsMappingAttribute(): void + { + $attr = new UploadableProperty(mappedBy: 'filePath'); + + self::assertInstanceOf(MappingAttribute::class, $attr); + } + + public function testEmptyMappedByThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must not be empty'); + + new UploadableProperty(mappedBy: ''); + } +} diff --git a/tests/Unit/Mapping/Attribute/UploadableTest.php b/tests/Unit/Mapping/Attribute/UploadableTest.php new file mode 100644 index 0000000..7edbc94 --- /dev/null +++ b/tests/Unit/Mapping/Attribute/UploadableTest.php @@ -0,0 +1,76 @@ +prefix); + self::assertSame(HashingNamingStrategy::class, $attr->namingStrategy); + self::assertSame(Behaviour::Remove, $attr->behaviour); + } + + public function testCustomValues(): void + { + $attr = new Uploadable( + prefix: 'custom', + namingStrategy: OriginNamingStrategy::class, + behaviour: Behaviour::Keep, + ); + + self::assertSame('custom', $attr->prefix); + self::assertSame(OriginNamingStrategy::class, $attr->namingStrategy); + self::assertSame(Behaviour::Keep, $attr->behaviour); + } + + public function testImplementsMappingAttribute(): void + { + $attr = new Uploadable(prefix: 'test'); + + self::assertInstanceOf(MappingAttribute::class, $attr); + } + + public function testPrefixWithPathTraversalThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must not contain ".."'); + + new Uploadable(prefix: '../secret'); + } + + public function testInvalidNamingStrategyThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('namingStrategy'); + + new Uploadable(prefix: 'test', namingStrategy: \stdClass::class); + } + + public function testEmptyNamingStrategyThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must not be empty'); + + new Uploadable(prefix: 'test', namingStrategy: ''); + } +} diff --git a/tests/Unit/Mapping/Configuration/UploadableConfigurationTest.php b/tests/Unit/Mapping/Configuration/UploadableConfigurationTest.php new file mode 100644 index 0000000..ab300fd --- /dev/null +++ b/tests/Unit/Mapping/Configuration/UploadableConfigurationTest.php @@ -0,0 +1,105 @@ +getPrefix()); + self::assertSame(Behaviour::Keep, $config->getBehaviour()); + self::assertSame(OriginNamingStrategy::class, $config->getNamingStrategy()); + } + + public function testConstructorWithNull(): void + { + $config = new UploadableConfiguration(null); + + self::assertSame('', $config->getPrefix()); + self::assertSame(Behaviour::Remove, $config->getBehaviour()); + self::assertSame(HashingNamingStrategy::class, $config->getNamingStrategy()); + } + + public function testGetUploadableFieldNames(): void + { + $config = new UploadableConfiguration(new Uploadable(prefix: 'test')); + $config->mapField('file', ['upload' => true, 'mappedBy' => 'filePath']); + $config->mapField('filePath', ['inversedBy' => 'file']); + + $uploadable = $config->getUploadableFieldNames(); + + self::assertSame(['file' => 'file'], $uploadable); + } + + public function testGetMappedByFieldNames(): void + { + $config = new UploadableConfiguration(new Uploadable(prefix: 'test')); + $config->mapField('file', ['upload' => true, 'mappedBy' => 'filePath']); + $config->mapField('filePath', ['inversedBy' => 'file']); + + $mappedBy = $config->getMappedByFieldNames(); + + self::assertSame(['filePath' => 'filePath'], $mappedBy); + } + + public function testSerializeUnserialize(): void + { + $annotation = new Uploadable( + prefix: 'photos', + namingStrategy: OriginNamingStrategy::class, + behaviour: Behaviour::Keep, + ); + + $config = new UploadableConfiguration($annotation); + $config->mapField('file', ['upload' => true, 'mappedBy' => 'filePath']); + $config->mapField('filePath', ['inversedBy' => 'file']); + + $serialized = \serialize($config); + /** @var UploadableConfiguration $restored */ + $restored = \unserialize($serialized); + + self::assertSame('photos', $restored->getPrefix()); + self::assertSame(Behaviour::Keep, $restored->getBehaviour()); + self::assertSame(OriginNamingStrategy::class, $restored->getNamingStrategy()); + self::assertSame(['file' => 'file'], $restored->getUploadableFieldNames()); + self::assertSame(['filePath' => 'filePath'], $restored->getMappedByFieldNames()); + } + + public function testSerializeUnserializePreservesMappedByFieldsNames(): void + { + $config = new UploadableConfiguration(new Uploadable(prefix: 'test')); + $config->mapField('file', ['upload' => true, 'mappedBy' => 'filePath']); + $config->mapField('filePath', ['inversedBy' => 'file']); + + // Force lazy computation of mappedByFieldsNames before serialization + $config->getMappedByFieldNames(); + + $restored = \unserialize(\serialize($config)); + + self::assertSame(['filePath' => 'filePath'], $restored->getMappedByFieldNames()); + } +} diff --git a/tests/Unit/Mapping/Driver/UploadableDriverTest.php b/tests/Unit/Mapping/Driver/UploadableDriverTest.php new file mode 100644 index 0000000..9e2e6fc --- /dev/null +++ b/tests/Unit/Mapping/Driver/UploadableDriverTest.php @@ -0,0 +1,160 @@ +driver = new UploadableDriver(new AttributeReader()); + } + + private function createClassMetadata(string $class): ClassMetadata + { + $classMetadata = new ClassMetadata($class); + $classMetadata->initializeReflection(new RuntimeReflectionService()); + + return $classMetadata; + } + + public function testLoadMetadataWithUploadableEntity(): void + { + $classMetadata = $this->createClassMetadata(UploadableEntity::class); + $metadata = $this->createMock(ExtensionMetadataInterface::class); + $metadata->method('getOriginMetadata')->willReturn($classMetadata); + $metadata->method('getName')->willReturn(UploadableEntity::class); + $metadata->method('getEmbeddedMetadataWithConfiguration')->willReturn([]); + + $metadata->expects(self::once()) + ->method('addConfiguration') + ->with(self::callback(function (UploadableConfiguration $config): bool { + $uploadFields = $config->getUploadableFieldNames(); + self::assertSame(['file' => 'file'], $uploadFields); + + $mappedFields = $config->getMappedByFieldNames(); + self::assertSame(['filePath' => 'filePath'], $mappedFields); + + $fileMapping = $config->getMapping('file'); + self::assertTrue($fileMapping['upload']); + self::assertSame('filePath', $fileMapping['mappedBy']); + + $pathMapping = $config->getMapping('filePath'); + self::assertSame('file', $pathMapping['inversedBy']); + + return true; + })); + + $this->driver->loadMetadataForClass($metadata); + } + + public function testLoadMetadataWithNonUploadableEntity(): void + { + $classMetadata = $this->createClassMetadata(NonUploadableEntity::class); + $metadata = $this->createMock(ExtensionMetadataInterface::class); + $metadata->method('getOriginMetadata')->willReturn($classMetadata); + $metadata->method('getName')->willReturn(NonUploadableEntity::class); + $metadata->method('getEmbeddedMetadataWithConfiguration')->willReturn([]); + + $metadata->expects(self::once()) + ->method('addConfiguration') + ->with(self::callback(function (UploadableConfiguration $config): bool { + self::assertSame([], $config->getUploadableFieldNames()); + + return true; + })); + + $this->driver->loadMetadataForClass($metadata); + } + + public function testThrowsForInvalidNamingStrategy(): void + { + if (!\class_exists(InvalidStrategyEntity::class, false)) { + eval(<<<'PHP' + namespace Tests\Unit\Mapping\Driver; + + use ChamberOrchestra\FileBundle\Mapping\Attribute as CO; + use Doctrine\ORM\Mapping as ORM; + use Symfony\Component\HttpFoundation\File\File; + + #[CO\Uploadable(prefix: 'test', namingStrategy: \stdClass::class)] + class InvalidStrategyEntity + { + #[ORM\Id] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[CO\UploadableProperty(mappedBy: 'filePath')] + private File|null $file = null; + + private string|null $filePath = null; + } + PHP); + } + + $classMetadata = $this->createClassMetadata(InvalidStrategyEntity::class); + $metadata = $this->createMock(ExtensionMetadataInterface::class); + $metadata->method('getOriginMetadata')->willReturn($classMetadata); + $metadata->method('getName')->willReturn(InvalidStrategyEntity::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('namingStrategy'); + + $this->driver->loadMetadataForClass($metadata); + } + + public function testThrowsForMissingMappedByProperty(): void + { + if (!\class_exists(MissingMappedByEntity::class, false)) { + eval(<<<'PHP' + namespace Tests\Unit\Mapping\Driver; + + use ChamberOrchestra\FileBundle\Mapping\Attribute as CO; + use Doctrine\ORM\Mapping as ORM; + use Symfony\Component\HttpFoundation\File\File; + + #[CO\Uploadable(prefix: 'test')] + class MissingMappedByEntity + { + #[ORM\Id] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[CO\UploadableProperty(mappedBy: 'nonExistentField')] + private File|null $file = null; + } + PHP); + } + + $classMetadata = $this->createClassMetadata(MissingMappedByEntity::class); + $metadata = $this->createMock(ExtensionMetadataInterface::class); + $metadata->method('getOriginMetadata')->willReturn($classMetadata); + $metadata->method('getName')->willReturn(MissingMappedByEntity::class); + + $this->expectException(BaseMappingException::class); + + $this->driver->loadMetadataForClass($metadata); + } +} diff --git a/tests/Unit/Model/FileTest.php b/tests/Unit/Model/FileTest.php new file mode 100644 index 0000000..3e16b2e --- /dev/null +++ b/tests/Unit/Model/FileTest.php @@ -0,0 +1,49 @@ +getPathname()); + self::assertSame('/uploads/test.txt', $file->getUri()); + } + + public function testConstructorWithNullUri(): void + { + $file = new File('/tmp/test.txt'); + + self::assertNull($file->getUri()); + } + + public function testImplementsFileInterface(): void + { + $file = new File('/tmp/test.txt', '/uploads/test.txt'); + + self::assertInstanceOf(FileInterface::class, $file); + } + + public function testFileDoesNotRequirePhysicalFile(): void + { + $file = new File('/nonexistent/path/file.txt', '/uploads/file.txt'); + + self::assertSame('/nonexistent/path/file.txt', $file->getPathname()); + self::assertSame('/uploads/file.txt', $file->getUri()); + } +} diff --git a/tests/Unit/Model/ImageTraitTest.php b/tests/Unit/Model/ImageTraitTest.php new file mode 100644 index 0000000..e330b96 --- /dev/null +++ b/tests/Unit/Model/ImageTraitTest.php @@ -0,0 +1,97 @@ +tempDir = \sys_get_temp_dir().'/image_trait_test_'.\uniqid(); + \mkdir($this->tempDir, 0777, true); + } + + protected function tearDown(): void + { + $files = \glob($this->tempDir.'/*'); + foreach ($files as $f) { + \unlink($f); + } + \rmdir($this->tempDir); + } + + public function testIsImageReturnsTrueForImage(): void + { + $path = $this->createTestImage(100, 50); + $file = new File($path, '/uploads/test.png'); + + self::assertTrue($file->isImage()); + } + + public function testIsImageReturnsFalseForNonImage(): void + { + $path = $this->tempDir.'/test.txt'; + \file_put_contents($path, 'not an image'); + $file = new File($path, '/uploads/test.txt'); + + self::assertFalse($file->isImage()); + } + + public function testIsImageReturnsFalseForNonExistentFile(): void + { + $file = new File('/nonexistent/path.png', '/uploads/path.png'); + + self::assertFalse($file->isImage()); + } + + public function testGetImageSizeReturnsCorrectDimensions(): void + { + $path = $this->createTestImage(100, 50); + $file = new File($path, '/uploads/test.png'); + + self::assertSame(100, $file->getWidth()); + self::assertSame(50, $file->getHeight()); + } + + public function testGetRatio(): void + { + $path = $this->createTestImage(100, 50); + $file = new File($path, '/uploads/test.png'); + + self::assertSame(2.0, $file->getRatio()); + } + + public function testGetImageSizeThrowsForNonImage(): void + { + $path = $this->tempDir.'/test.txt'; + \file_put_contents($path, 'not an image'); + $file = new File($path, '/uploads/test.txt'); + + $this->expectException(RuntimeException::class); + $file->getImageSize(); + } + + private function createTestImage(int $width, int $height): string + { + $image = \imagecreatetruecolor($width, $height); + $path = $this->tempDir.'/test.png'; + \imagepng($image, $path); + \imagedestroy($image); + + return $path; + } +} diff --git a/tests/Unit/NamingStrategy/HashingNamingStrategyTest.php b/tests/Unit/NamingStrategy/HashingNamingStrategyTest.php new file mode 100644 index 0000000..f44a760 --- /dev/null +++ b/tests/Unit/NamingStrategy/HashingNamingStrategyTest.php @@ -0,0 +1,80 @@ +strategy = new HashingNamingStrategy(); + } + + public function testNameReturnsHashWithExtension(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'test'); + \file_put_contents($tmpFile, 'test content'); + + $file = new File($tmpFile); + $name = $this->strategy->name($file); + + self::assertMatchesRegularExpression('/^[a-f0-9]{32}\..+$/', $name); + + \unlink($tmpFile); + } + + public function testNameWithUploadedFileUsesClientOriginalName(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'test'); + \file_put_contents($tmpFile, 'test content'); + + $file = new UploadedFile($tmpFile, 'original.txt', 'text/plain', null, true); + $name = $this->strategy->name($file); + + self::assertMatchesRegularExpression('/^[a-f0-9]{32}\..+$/', $name); + + \unlink($tmpFile); + } + + public function testNameWithRegularFileUsesBasename(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'test'); + \file_put_contents($tmpFile, 'test content'); + + $file = new File($tmpFile); + $name = $this->strategy->name($file); + + self::assertMatchesRegularExpression('/^[a-f0-9]{32}\..+$/', $name); + + \unlink($tmpFile); + } + + public function testNameGeneratesUniqueNamesForSameFile(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'test'); + \file_put_contents($tmpFile, 'test content'); + + $file = new File($tmpFile); + $name1 = $this->strategy->name($file); + $name2 = $this->strategy->name($file); + + self::assertNotSame($name1, $name2); + + \unlink($tmpFile); + } +} diff --git a/tests/Unit/NamingStrategy/NamingStrategyFactoryTest.php b/tests/Unit/NamingStrategy/NamingStrategyFactoryTest.php new file mode 100644 index 0000000..0db1c85 --- /dev/null +++ b/tests/Unit/NamingStrategy/NamingStrategyFactoryTest.php @@ -0,0 +1,61 @@ +getProperty('factories'); + $prop->setValue(null, []); + } + + public function testCreateReturnsCorrectInstance(): void + { + $hashing = NamingStrategyFactory::create(HashingNamingStrategy::class); + self::assertInstanceOf(HashingNamingStrategy::class, $hashing); + + $origin = NamingStrategyFactory::create(OriginNamingStrategy::class); + self::assertInstanceOf(OriginNamingStrategy::class, $origin); + } + + public function testCreateReturnsCachedInstance(): void + { + $first = NamingStrategyFactory::create(HashingNamingStrategy::class); + $second = NamingStrategyFactory::create(HashingNamingStrategy::class); + + self::assertSame($first, $second); + } + + public function testCreateThrowsForNonExistentClass(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('does not exist'); + + NamingStrategyFactory::create('App\\NonExistent\\Strategy'); + } + + public function testCreateThrowsForNonImplementingClass(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must implement'); + + NamingStrategyFactory::create(\stdClass::class); + } +} diff --git a/tests/Unit/NamingStrategy/OriginNamingStrategyTest.php b/tests/Unit/NamingStrategy/OriginNamingStrategyTest.php new file mode 100644 index 0000000..3c08b39 --- /dev/null +++ b/tests/Unit/NamingStrategy/OriginNamingStrategyTest.php @@ -0,0 +1,68 @@ +strategy = new OriginNamingStrategy(); + } + + public function testNameWithRegularFileReturnsBasename(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'test'); + \file_put_contents($tmpFile, 'content'); + + $file = new File($tmpFile); + $name = $this->strategy->name($file); + + self::assertSame(\basename($tmpFile), $name); + + \unlink($tmpFile); + } + + public function testNameWithUploadedFileReturnsClientOriginalName(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'test'); + \file_put_contents($tmpFile, 'content'); + + $file = new UploadedFile($tmpFile, 'my-photo.jpg', 'image/jpeg', null, true); + $name = $this->strategy->name($file); + + self::assertSame('my-photo.jpg', $name); + + \unlink($tmpFile); + } + + public function testNameStripsPathTraversalFromUploadedFile(): void + { + $tmpFile = \tempnam(\sys_get_temp_dir(), 'test'); + \file_put_contents($tmpFile, 'content'); + + $file = new UploadedFile($tmpFile, '../../etc/passwd', 'text/plain', null, true); + $name = $this->strategy->name($file); + + self::assertSame('passwd', $name); + self::assertStringNotContainsString('..', $name); + self::assertStringNotContainsString('/', $name); + + \unlink($tmpFile); + } +} diff --git a/tests/Unit/Serializer/Normalizer/FileNormalizerTest.php b/tests/Unit/Serializer/Normalizer/FileNormalizerTest.php new file mode 100644 index 0000000..153cf6e --- /dev/null +++ b/tests/Unit/Serializer/Normalizer/FileNormalizerTest.php @@ -0,0 +1,104 @@ +normalize($file); + + self::assertSame('https://example.com/uploads/test.txt', $result); + } + + public function testNormalizeWithBaseUrlTrailingSlash(): void + { + $normalizer = new FileNormalizer('https://example.com/'); + $file = new File('/tmp/test.txt', '/uploads/test.txt'); + + $result = $normalizer->normalize($file); + + self::assertSame('https://example.com/uploads/test.txt', $result); + } + + public function testNormalizeWithoutBaseUrl(): void + { + $normalizer = new FileNormalizer(); + $file = new File('/tmp/test.txt', '/uploads/test.txt'); + + $result = $normalizer->normalize($file); + + self::assertSame('/uploads/test.txt', $result); + } + + public function testSupportsNormalizationForModelFile(): void + { + $normalizer = new FileNormalizer(); + $file = new File('/tmp/test.txt', '/uploads/test.txt'); + + self::assertTrue($normalizer->supportsNormalization($file)); + } + + public function testSupportsNormalizationForOtherTypes(): void + { + $normalizer = new FileNormalizer(); + + self::assertFalse($normalizer->supportsNormalization(new \stdClass())); + self::assertFalse($normalizer->supportsNormalization('string')); + } + + public function testNormalizeWithNullUriReturnsNull(): void + { + $normalizer = new FileNormalizer('https://example.com'); + $file = new File('/tmp/test.txt', null); + + $result = $normalizer->normalize($file); + + self::assertNull($result); + } + + public function testGetSupportedTypes(): void + { + $normalizer = new FileNormalizer(); + + $types = $normalizer->getSupportedTypes(null); + + self::assertSame([File::class => true], $types); + } + + public function testNormalizeWithAbsoluteHttpsUri(): void + { + $normalizer = new FileNormalizer('https://example.com'); + $file = new File('/tmp/test.txt', 'https://my-bucket.s3.amazonaws.com/test/file.txt'); + + $result = $normalizer->normalize($file); + + self::assertSame('https://my-bucket.s3.amazonaws.com/test/file.txt', $result); + } + + public function testNormalizeWithAbsoluteHttpUri(): void + { + $normalizer = new FileNormalizer('https://example.com'); + $file = new File('/tmp/test.txt', 'http://cdn.example.com/uploads/test.txt'); + + $result = $normalizer->normalize($file); + + self::assertSame('http://cdn.example.com/uploads/test.txt', $result); + } +} diff --git a/tests/Unit/Storage/FileSystemStorageTest.php b/tests/Unit/Storage/FileSystemStorageTest.php new file mode 100644 index 0000000..a6d4e20 --- /dev/null +++ b/tests/Unit/Storage/FileSystemStorageTest.php @@ -0,0 +1,245 @@ +uploadPath = \sys_get_temp_dir().'/file_system_storage_test_'.\uniqid(); + \mkdir($this->uploadPath, 0777, true); + + $this->storage = new FileSystemStorage($this->uploadPath, '/uploads'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->uploadPath); + } + + public function testUploadMovesFileAndReturnsRelativePath(): void + { + $tmpFile = $this->uploadPath.'/source.txt'; + \file_put_contents($tmpFile, 'content'); + $file = new File($tmpFile); + + $namingStrategy = $this->createMock(NamingStrategyInterface::class); + $namingStrategy->method('name')->willReturn('renamed.txt'); + + $relativePath = $this->storage->upload($file, $namingStrategy); + + self::assertSame('/renamed.txt', $relativePath); + self::assertFileExists($this->uploadPath.'/renamed.txt'); + } + + public function testUploadWithEmptyPrefix(): void + { + $tmpFile = $this->uploadPath.'/source.txt'; + \file_put_contents($tmpFile, 'content'); + $file = new File($tmpFile); + + $namingStrategy = $this->createMock(NamingStrategyInterface::class); + $namingStrategy->method('name')->willReturn('file.txt'); + + $result = $this->storage->upload($file, $namingStrategy, ''); + + self::assertSame('/file.txt', $result); + } + + public function testUploadWithPrefix(): void + { + $tmpFile = $this->uploadPath.'/source.txt'; + \file_put_contents($tmpFile, 'content'); + $file = new File($tmpFile); + + $namingStrategy = $this->createMock(NamingStrategyInterface::class); + $namingStrategy->method('name')->willReturn('file.txt'); + + $result = $this->storage->upload($file, $namingStrategy, 'avatars'); + + self::assertSame('/avatars/file.txt', $result); + self::assertFileExists($this->uploadPath.'/avatars/file.txt'); + } + + public function testRemoveDeletesExistingFile(): void + { + $filePath = $this->uploadPath.'/to-delete.txt'; + \file_put_contents($filePath, 'content'); + + self::assertTrue($this->storage->remove($filePath)); + self::assertFileDoesNotExist($filePath); + } + + public function testRemoveReturnsFalseForNonExistent(): void + { + self::assertFalse($this->storage->remove($this->uploadPath.'/nonexistent.txt')); + } + + public function testResolvePathPrependsUploadPath(): void + { + $result = $this->storage->resolvePath('/test/file.txt'); + + self::assertSame($this->uploadPath.'/test/file.txt', $result); + } + + public function testResolveUriPrependsUriPrefix(): void + { + $result = $this->storage->resolveUri('/test/file.txt'); + + self::assertSame('/uploads/test/file.txt', $result); + } + + public function testResolveUriReturnsNullWithoutPrefix(): void + { + $storage = new FileSystemStorage($this->uploadPath); + + self::assertNull($storage->resolveUri('/test/file.txt')); + } + + public function testResolveRelativePathStripsUploadPath(): void + { + $fullPath = $this->uploadPath.'/test/file.txt'; + + $result = $this->storage->resolveRelativePath($fullPath); + + self::assertSame('/test/file.txt', $result); + } + + public function testResolveRelativePathReturnsPathAsIsWhenNotPrefixed(): void + { + $result = $this->storage->resolveRelativePath('/some/other/path/file.txt'); + + self::assertSame('/some/other/path/file.txt', $result); + } + + public function testResolveRelativePathDoesNotReplaceMiddleOccurrences(): void + { + // Ensure that the upload path appearing in the middle of the string is not stripped + $pathWithUploadPathInMiddle = '/prefix'.$this->uploadPath.'/file.txt'; + + $result = $this->storage->resolveRelativePath($pathWithUploadPathInMiddle); + + self::assertSame($pathWithUploadPathInMiddle, $result); + } + + public function testUploadRejectsFilenameWithDirectorySeparator(): void + { + $tmpFile = $this->uploadPath.'/source.txt'; + \file_put_contents($tmpFile, 'content'); + $file = new File($tmpFile); + + $namingStrategy = $this->createMock(NamingStrategyInterface::class); + $namingStrategy->method('name')->willReturn('../etc/passwd'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('must not contain directory separators'); + + $this->storage->upload($file, $namingStrategy); + } + + public function testUploadRejectsFilenameWithBackslash(): void + { + $tmpFile = $this->uploadPath.'/source.txt'; + \file_put_contents($tmpFile, 'content'); + $file = new File($tmpFile); + + $namingStrategy = $this->createMock(NamingStrategyInterface::class); + $namingStrategy->method('name')->willReturn('..\\etc\\passwd'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('must not contain directory separators'); + + $this->storage->upload($file, $namingStrategy); + } + + public function testUploadRejectsFilenameWithDotDot(): void + { + $tmpFile = $this->uploadPath.'/source.txt'; + \file_put_contents($tmpFile, 'content'); + $file = new File($tmpFile); + + $namingStrategy = $this->createMock(NamingStrategyInterface::class); + $namingStrategy->method('name')->willReturn('..malicious.txt'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('must not contain directory separators'); + + $this->storage->upload($file, $namingStrategy); + } + + public function testResolvePathRejectsPathTraversal(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Path traversal detected'); + + $this->storage->resolvePath('/../../../etc/passwd'); + } + + public function testDownloadRejectsPathTraversal(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Path traversal detected'); + + $this->storage->download('/../../etc/passwd', '/tmp/target.txt'); + } + + public function testDownloadCopiesFileToTarget(): void + { + $sourceFile = $this->uploadPath.'/test/file.txt'; + \mkdir(\dirname($sourceFile), 0777, true); + \file_put_contents($sourceFile, 'downloaded content'); + + $targetPath = $this->uploadPath.'/archive/file.txt'; + \mkdir(\dirname($targetPath), 0777, true); + + $this->storage->download('/test/file.txt', $targetPath); + + self::assertFileExists($targetPath); + self::assertSame('downloaded content', \file_get_contents($targetPath)); + // Source file should still exist + self::assertFileExists($sourceFile); + } + + public function testDownloadThrowsWhenSourceDoesNotExist(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not exist'); + + $this->storage->download('/nonexistent/file.txt', $this->uploadPath.'/target.txt'); + } + + private function removeDirectory(string $dir): void + { + if (!\is_dir($dir)) { + return; + } + + $items = \scandir($dir); + foreach ($items as $item) { + if ('.' === $item || '..' === $item) { + continue; + } + $path = $dir.'/'.$item; + \is_dir($path) ? $this->removeDirectory($path) : \unlink($path); + } + \rmdir($dir); + } +} diff --git a/tests/Unit/Storage/StorageResolverTest.php b/tests/Unit/Storage/StorageResolverTest.php new file mode 100644 index 0000000..905054b --- /dev/null +++ b/tests/Unit/Storage/StorageResolverTest.php @@ -0,0 +1,76 @@ +createMock(StorageInterface::class); + + $resolver->add('concert_hall', $storage); + + self::assertSame($storage, $resolver->get('concert_hall')); + } + + public function testGetThrowsForUnknownStorage(): void + { + $resolver = new StorageResolver(); + $resolver->add('default', $this->createMock(StorageInterface::class)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Storage "backstage" is not registered'); + + $resolver->get('backstage'); + } + + public function testAddThrowsForEmptyName(): void + { + $resolver = new StorageResolver(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Storage name must not be empty'); + + $resolver->add('', $this->createMock(StorageInterface::class)); + } + + public function testMultipleStoragesCanBeRegistered(): void + { + $resolver = new StorageResolver(); + $public = $this->createMock(StorageInterface::class); + $secure = $this->createMock(StorageInterface::class); + + $resolver->add('public', $public); + $resolver->add('secure', $secure); + + self::assertSame($public, $resolver->get('public')); + self::assertSame($secure, $resolver->get('secure')); + } + + public function testAddOverwritesExistingStorage(): void + { + $resolver = new StorageResolver(); + $first = $this->createMock(StorageInterface::class); + $second = $this->createMock(StorageInterface::class); + + $resolver->add('default', $first); + $resolver->add('default', $second); + + self::assertSame($second, $resolver->get('default')); + } +} From aa70e847e671b337033cf0f6156f92d939f8e492 Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 19 Feb 2026 23:55:20 +0000 Subject: [PATCH 2/4] Remove .claude/settings.local.json from tracking Co-Authored-By: Claude Opus 4.6 --- .claude/settings.local.json | 46 ------------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 386290d..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(composer install:*)", - "Bash(./vendor/bin/phpunit:*)", - "Bash(vendor/bin/phpunit:*)", - "Bash(composer show:*)", - "WebSearch", - "WebFetch(domain:php.watch)", - "Bash(composer test:*)", - "Bash(git status:*)", - "Bash(git reset:*)", - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(git rm:*)", - "Bash(git push:*)", - "Bash(gh pr create:*)", - "Bash(gh repo view:*)", - "Bash(gh repo edit:*)", - "Bash(composer validate:*)", - "Bash(composer update:*)", - "Bash(composer run-script:*)", - "Bash(vendor/bin/phpstan analyse:*)", - "Bash(git -C /Users/andrewlukin/Work/chamber-orchestra/pagination-bundle status)", - "Bash(git -C /Users/andrewlukin/Work/chamber-orchestra/pagination-bundle diff)", - "Bash(git -C /Users/andrewlukin/Work/chamber-orchestra/pagination-bundle log --oneline -5)", - "Bash(git -C /Users/andrewlukin/Work/chamber-orchestra/pagination-bundle add .github/dependabot.yml .github/workflows/php.yml .gitignore README.md composer.json .php-cs-fixer.dist.php phpstan.neon phpstan-baseline.neon src/ tests/)", - "Bash(git -C:*)", - "Bash(git fetch:*)", - "Bash(git merge:*)", - "Bash(vendor/bin/php-cs-fixer fix:*)", - "Bash(gh pr list:*)", - "Bash(git checkout:*)", - "Bash(git pull:*)", - "Bash(composer analyse:*)", - "Bash(git stash:*)", - "Bash(composer cs-check:*)", - "Bash(composer require:*)", - "Bash(php -r:*)", - "Bash(php vendor/bin/phpunit:*)", - "Bash(git mv:*)", - "Bash(./vendor/bin/php-cs-fixer fix:*)", - "Bash(./vendor/bin/phpstan:*)" - ] - } -} From 1f3154f440edd215716436b4e1cd975ab9fcdef7 Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 19 Feb 2026 23:56:27 +0000 Subject: [PATCH 3/4] Improve SEO: keywords, badges, and feature list Co-Authored-By: Claude Opus 4.6 --- README.md | 23 ++++++++++++++++++++--- composer.json | 12 ++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5196a49..7e6bee2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,25 @@ # ChamberOrchestra File Bundle -A Symfony bundle for automatic file upload handling on Doctrine ORM entities. Mark your entity with PHP attributes, and the bundle transparently uploads, injects, and removes files through Doctrine lifecycle events. - -Supports local filesystem and Amazon S3 storage backends, multiple named storages, pluggable naming strategies, and Doctrine embeddables. +[![Latest Stable Version](https://img.shields.io/packagist/v/chamber-orchestra/file-bundle.svg)](https://packagist.org/packages/chamber-orchestra/file-bundle) +[![License](https://img.shields.io/packagist/l/chamber-orchestra/file-bundle.svg)](https://packagist.org/packages/chamber-orchestra/file-bundle) +[![PHP Version](https://img.shields.io/packagist/php-v/chamber-orchestra/file-bundle.svg)](https://packagist.org/packages/chamber-orchestra/file-bundle) + +A Symfony bundle for automatic file upload and image upload handling on Doctrine ORM entities. Mark your entity with PHP attributes, and the bundle transparently uploads, injects, and removes files through Doctrine lifecycle events. + +Supports local filesystem and Amazon S3 storage backends, multiple named storages, CDN integration, pluggable naming strategies, file archiving, and Doctrine embeddables. + +### Features + +- **Automatic file uploads** via Doctrine lifecycle events — no manual upload logic +- **Multiple storage backends** — local filesystem, Amazon S3, MinIO +- **Per-entity storage** — different entities can use different storages +- **CDN support** — serve files through CloudFront, Cloudflare, or any CDN +- **Private/secure storage** — store files outside the web root with controlled access +- **File archiving** — archive files before deletion instead of permanent removal +- **Image support** — dimensions, EXIF metadata, orientation detection +- **Doctrine embeddables** — uploadable fields inside embedded objects +- **Pluggable naming strategies** — hashing (default), original name, or custom +- **Symfony Serializer integration** — normalizes files to absolute URLs ## Requirements diff --git a/composer.json b/composer.json index 5c4a69f..3ffcab9 100644 --- a/composer.json +++ b/composer.json @@ -1,16 +1,24 @@ { "name": "chamber-orchestra/file-bundle", "type": "symfony-bundle", - "description": "Symfony bundle for automatic file upload handling on Doctrine ORM entities", + "description": "Symfony bundle for automatic file upload handling on Doctrine ORM entities with S3, CDN, and multiple storage support", "keywords": [ "symfony", "symfony-bundle", "file", + "file-upload", "upload", "doctrine", "orm", "storage", - "s3" + "s3", + "aws", + "cdn", + "image", + "image-upload", + "file-manager", + "file-storage", + "minio" ], "homepage": "https://github.com/chamber-orchestra/file-bundle", "license": "MIT", From d3cc6353a5a16e58410280a0793ec74a920e94c3 Mon Sep 17 00:00:00 2001 From: Dev Date: Fri, 20 Feb 2026 00:00:53 +0000 Subject: [PATCH 4/4] Fix CS fixer violation in UploadableConfiguration::__unserialize Co-Authored-By: Claude Opus 4.6 --- .../Configuration/UploadableConfiguration.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Mapping/Configuration/UploadableConfiguration.php b/src/Mapping/Configuration/UploadableConfiguration.php index 63a5137..03a1abe 100644 --- a/src/Mapping/Configuration/UploadableConfiguration.php +++ b/src/Mapping/Configuration/UploadableConfiguration.php @@ -109,12 +109,13 @@ public function __unserialize(array $data): void { parent::__unserialize($data); - /** @var array{mappings: array>, prefix: string, behaviour: Behaviour, namingStrategy: string, storage: string, uploadableFieldsNames: array|null, mappedByFieldsNames: array|null} $data */ - $this->prefix = $data['prefix']; - $this->behaviour = $data['behaviour']; - $this->namingStrategy = $data['namingStrategy']; - $this->storage = $data['storage']; - $this->uploadableFieldsNames = $data['uploadableFieldsNames']; - $this->mappedByFieldsNames = $data['mappedByFieldsNames']; + /** @var array{prefix: string, behaviour: Behaviour, namingStrategy: string, storage: string, uploadableFieldsNames: array|null, mappedByFieldsNames: array|null} $typed */ + $typed = $data; + $this->prefix = $typed['prefix']; + $this->behaviour = $typed['behaviour']; + $this->namingStrategy = $typed['namingStrategy']; + $this->storage = $typed['storage']; + $this->uploadableFieldsNames = $typed['uploadableFieldsNames']; + $this->mappedByFieldsNames = $typed['mappedByFieldsNames']; } }