Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.48% covered (success)
84.48%
49 / 58
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
StorageFactory
84.48% covered (success)
84.48%
49 / 58
71.43% covered (warning)
71.43%
5 / 7
30.93
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 createS3Storage
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
9.12
 resolveFilesystemRoot
81.82% covered (success)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
9.49
 readRequiredConfig
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 readStringConfig
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 tenantPrefix
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Storage factory.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-08
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Storage;
21
22use Aws\S3\S3Client;
23use phpMyFAQ\Configuration;
24use phpMyFAQ\Tenant\TenantContext;
25
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}