From afac06326e900da03a74a43fe49e924913f918f6 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 13 Mar 2026 14:46:03 +0300 Subject: [PATCH] feat: add S3 path_style, credentials config + File model relativePath support Add path_style and credentials options to S3 storage configuration for LocalStack/MinIO compatibility. Add relativePath to File model for image-bundle filter resolution with S3 storage. Fix S3Storage URI resolution for absolute URL prefixes. Co-Authored-By: Claude Opus 4.6 --- .../ChamberOrchestraFileExtension.php | 13 ++++- src/DependencyInjection/Configuration.php | 10 ++++ src/Handler/Handler.php | 4 +- src/Model/File.php | 49 ++++++++++++++++++- src/Model/FileInterface.php | 2 + src/Storage/S3Storage.php | 11 ++++- tests/Unit/Model/ImageTraitTest.php | 9 +++- 7 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/DependencyInjection/ChamberOrchestraFileExtension.php b/src/DependencyInjection/ChamberOrchestraFileExtension.php index dc744e3..93f6e7c 100644 --- a/src/DependencyInjection/ChamberOrchestraFileExtension.php +++ b/src/DependencyInjection/ChamberOrchestraFileExtension.php @@ -103,7 +103,7 @@ private function registerFileSystemStorage(ContainerBuilder $container, string $ } /** - * @param array{driver: string, path: string, uri_prefix: string|null, enabled?: bool, bucket?: string|null, region?: string|null, endpoint?: string|null} $storage + * @param array{driver: string, path: string, uri_prefix: string|null, enabled?: bool, bucket?: string|null, region?: string|null, endpoint?: string|null, path_style?: bool, credentials?: array{key: string, secret: string}} $storage */ private function registerS3Storage(ContainerBuilder $container, string $serviceId, string $name, array $storage): void { @@ -123,6 +123,17 @@ private function registerS3Storage(ContainerBuilder $container, string $serviceI $clientArgs['endpoint'] = $storage['endpoint']; } + if ($storage['path_style'] ?? false) { + $clientArgs['use_path_style_endpoint'] = true; + } + + if (isset($storage['credentials'])) { + $clientArgs['credentials'] = [ + 'key' => $storage['credentials']['key'], + 'secret' => $storage['credentials']['secret'], + ]; + } + $clientDefinition = new Definition(S3Client::class, [$clientArgs]); $container->setDefinition($serviceId.'.client', $clientDefinition); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 824f4e4..564a635 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -61,6 +61,16 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarNode('endpoint') ->defaultNull() ->end() + ->booleanNode('path_style') + ->defaultFalse() + ->info('Use path-style addressing (required for LocalStack and some S3-compatible services).') + ->end() + ->arrayNode('credentials') + ->children() + ->scalarNode('key')->isRequired()->end() + ->scalarNode('secret')->isRequired()->end() + ->end() + ->end() ->end() ->validate() ->ifTrue(static fn (array $v): bool => 's3' === ($v['driver'] ?? 'file_system') && (null === $v['bucket'] || '' === $v['bucket'])) diff --git a/src/Handler/Handler.php b/src/Handler/Handler.php index ba93f23..d6f0cb7 100644 --- a/src/Handler/Handler.php +++ b/src/Handler/Handler.php @@ -96,7 +96,7 @@ public function upload(ExtensionMetadataInterface $metadata, object $object, str $resolvedPath = $storage->resolvePath($relativePath); $uri = $storage->resolveUri($relativePath); - $file = new File($resolvedPath, $uri); + $file = new File($resolvedPath, $uri, $relativePath); $metadata->setFieldValue($object, $inversedBy, $file); $this->dispatcher->dispatch(new PostUploadEvent($object, $file, $fieldName)); @@ -163,7 +163,7 @@ public function inject(ExtensionMetadataInterface $metadata, object $object, str $path = $storage->resolvePath($relativePath); $uri = $storage->resolveUri($relativePath); - $file = new File($path, $uri); + $file = new File($path, $uri, $relativePath); $metadata->setFieldValue($object, $fieldName, $file); } diff --git a/src/Model/File.php b/src/Model/File.php index a70ade8..9806c24 100644 --- a/src/Model/File.php +++ b/src/Model/File.php @@ -15,8 +15,11 @@ class File extends \Symfony\Component\HttpFoundation\File\File implements FileIn { use ImageTrait; - public function __construct(string $path, public readonly ?string $uri = null) - { + public function __construct( + string $path, + public readonly ?string $uri = null, + private readonly ?string $relativePath = null, + ) { parent::__construct($path, false); } @@ -24,4 +27,46 @@ public function getUri(): ?string { return $this->uri; } + + public function getRelativePath(): ?string + { + return $this->relativePath; + } + + #[\Override] + public function isFile(): bool + { + if (parent::isFile()) { + return true; + } + + return null !== $this->uri; + } + + #[\Override] + public function getMimeType(): ?string + { + if (parent::isFile()) { + return parent::getMimeType(); + } + + if (null !== $this->uri) { + $ext = \pathinfo($this->getPathname(), \PATHINFO_EXTENSION); + + return match (\strtolower($ext)) { + 'jpg', 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'avif' => 'image/avif', + 'svg' => 'image/svg+xml', + 'pdf' => 'application/pdf', + 'mp4' => 'video/mp4', + 'mov' => 'video/quicktime', + default => null, + }; + } + + return null; + } } diff --git a/src/Model/FileInterface.php b/src/Model/FileInterface.php index 4930100..da193d5 100644 --- a/src/Model/FileInterface.php +++ b/src/Model/FileInterface.php @@ -14,4 +14,6 @@ interface FileInterface { public function getUri(): ?string; + + public function getRelativePath(): ?string; } diff --git a/src/Storage/S3Storage.php b/src/Storage/S3Storage.php index 6154ce1..1b7dd07 100644 --- a/src/Storage/S3Storage.php +++ b/src/Storage/S3Storage.php @@ -28,7 +28,14 @@ public function __construct( ?string $uriPrefix = null, ) { $this->bucket = $bucket; - $this->uriPrefix = null !== $uriPrefix ? '/'.\trim($uriPrefix, '/') : null; + + if (null === $uriPrefix) { + $this->uriPrefix = null; + } elseif (\str_contains($uriPrefix, '://')) { + $this->uriPrefix = \rtrim($uriPrefix, '/'); + } else { + $this->uriPrefix = '/'.\trim($uriPrefix, '/'); + } } public function upload(File $file, NamingStrategyInterface $namingStrategy, string $prefix = ''): string @@ -90,7 +97,7 @@ public function resolveUri(string $path): ?string return $this->client->getObjectUrl($this->bucket, \ltrim($path, '/')); } - return $this->uriPrefix.$path; + return $this->uriPrefix.'/'.\ltrim($path, '/'); } public function resolveRelativePath(string $path, string $prefix = ''): string diff --git a/tests/Unit/Model/ImageTraitTest.php b/tests/Unit/Model/ImageTraitTest.php index e330b96..291e1dc 100644 --- a/tests/Unit/Model/ImageTraitTest.php +++ b/tests/Unit/Model/ImageTraitTest.php @@ -51,10 +51,17 @@ public function testIsImageReturnsFalseForNonImage(): void self::assertFalse($file->isImage()); } - public function testIsImageReturnsFalseForNonExistentFile(): void + public function testIsImageReturnsTrueForNonExistentFileWithUri(): void { $file = new File('/nonexistent/path.png', '/uploads/path.png'); + self::assertTrue($file->isImage()); + } + + public function testIsImageReturnsFalseForNonExistentFileWithoutUri(): void + { + $file = new File('/nonexistent/path.png'); + self::assertFalse($file->isImage()); }