Lines 84.48% 49 / 58
Methods 71.42% 5 / 7
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 create 100.00% 9 / 9 100.00% 1 / 1 4
 createS3Storage 74.07% 20 / 27 0.00% 0 / 1 9.12
 resolveFilesystemRoot 81.81% 9 / 11 0.00% 0 / 1 9.49
 readRequiredConfig 100.00% 4 / 4 100.00% 1 / 1 3
 readStringConfig 100.00% 4 / 4 100.00% 1 / 1 2
 tenantPrefix 100.00% 2 / 2 100.00% 1 / 1 1
26final readonly class StorageFactory
27{
28    public function __construct(
29        private Configuration $configuration,
30        private ?TenantContext $tenantContext = null,
31    ) {
32    }
33
34    public function create(): StorageInterface
35    {
36        $type = strtolower((string) ($this->configuration->get('storage.type') ?? 'filesystem'));
37
38        $storage = match ($type) {
39            'filesystem' => new FilesystemStorage(
40                $this->resolveFilesystemRoot(),
41                $this->readStringConfig('storage.filesystem.publicBaseUrl'),
42            ),
43            's3' => $this->createS3Storage(),
44            default => throw new StorageException('Unsupported storage type: ' . $type),
45        };
46
47        return new TenantScopedStorage($storage, $this->tenantPrefix());
48    }
49
50    private function createS3Storage(): S3Storage
51    {
52        $bucket = $this->readRequiredConfig('storage.s3.bucket');
53        $prefix = $this->readStringConfig('storage.s3.prefix') ?? '';
54        $publicBaseUrl = $this->readStringConfig('storage.s3.publicBaseUrl');
55        $region = $this->readStringConfig('storage.s3.region') ?? 'us-east-1';
56
57        if (!class_exists(S3Client::class)) {
58            throw new StorageException('AWS SDK for PHP is required for S3 storage.');
59        }
60
61        $s3Config = [
62            'version' => 'latest',
63            'region' => $region,
64        ];
65
66        $endpoint = $this->readStringConfig('storage.s3.endpoint');
67        if ($endpoint !== null && $endpoint !== '') {
68            $s3Config['endpoint'] = $endpoint;
69        }
70
71        $key = $this->readStringConfig('storage.s3.key');
72        $secret = $this->readStringConfig('storage.s3.secret');
73        if (($key === null) !== ($secret === null)) {
74            throw new StorageException('Both storage.s3.key and storage.s3.secret must be provided together.');
75        }
76
77        if ($key !== null && $secret !== null) {
78            $s3Config['credentials'] = [
79                'key' => $key,
80                'secret' => $secret,
81            ];
82        }
83
84        $usePathStyle = $this->configuration->get('storage.s3.usePathStyle');
85        if ($usePathStyle !== null) {
86            $s3Config['use_path_style_endpoint'] = filter_var($usePathStyle, FILTER_VALIDATE_BOOL);
87        }
88
89        $client = new S3Client($s3Config);
90
91        return new S3Storage($client, $bucket, $prefix, $publicBaseUrl);
92    }
93
94    private function resolveFilesystemRoot(): string
95    {
96        $configuredRoot = $this->readStringConfig('storage.filesystem.root');
97        $root = $configuredRoot;
98        if ($root === null || $root === '') {
99            $root = defined('PMF_ATTACHMENTS_DIR') && PMF_ATTACHMENTS_DIR !== false
100                ? (string) PMF_ATTACHMENTS_DIR
101                : (string) PMF_ROOT_DIR . '/content/user/attachments';
102        }
103
104        /* @mago-expect lint:no-error-control-operator - mkdir may race a concurrent request; the re-check handles it */
105        if (!is_dir($root) && !@mkdir($root, permissions: 0o775, recursive: true) && !is_dir($root)) {
106            throw new StorageException('Storage root directory could not be created: ' . $root);
107        }
108
109        if (!is_writable($root)) {
110            throw new StorageException('Storage root directory is not writable: ' . $root);
111        }
112
113        return $root;
114    }
115
116    private function readRequiredConfig(string $key): string
117    {
118        $value = $this->readStringConfig($key);
119        if ($value === null || $value === '') {
120            throw new StorageException('Missing required storage configuration key: ' . $key);
121        }
122
123        return $value;
124    }
125
126    private function readStringConfig(string $key): ?string
127    {
128        $value = $this->configuration->get($key);
129        if ($value === null) {
130            return null;
131        }
132
133        return trim((string) $value);
134    }
135
136    private function tenantPrefix(): string
137    {
138        $tenantId = $this->tenantContext?->getTenantId() ?? 0;
139
140        return sprintf('%d/attachments', $tenantId);
141    }
142}