Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.10% covered (success)
97.10%
67 / 69
83.33% covered (success)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
S3Storage
97.10% covered (success)
97.10%
67 / 69
83.33% covered (success)
83.33%
10 / 12
27
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
 put
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 putStream
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 get
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 exists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 url
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 size
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 run
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 buildKey
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 hasResultField
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getResultField
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
1<?php
2
3/**
4 * S3 storage implementation.
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 ArrayAccess;
23use Throwable;
24
25final readonly class S3Storage implements StorageInterface
26{
27    public function __construct(
28        private object $client,
29        private string $bucket,
30        private string $prefix = '',
31        private ?string $publicBaseUrl = null,
32    ) {
33    }
34
35    public function put(string $path, string $contents): bool
36    {
37        $key = $this->buildKey($path);
38        $this->run(fn(): mixed => $this->client->putObject([
39            'Bucket' => $this->bucket,
40            'Key' => $key,
41            'Body' => $contents,
42        ]));
43
44        return true;
45    }
46
47    public function putStream(string $path, mixed $stream): bool
48    {
49        if (!is_resource($stream)) {
50            throw new StorageException('Stream must be a valid resource.');
51        }
52
53        $key = $this->buildKey($path);
54        $this->run(fn(): mixed => $this->client->putObject([
55            'Bucket' => $this->bucket,
56            'Key' => $key,
57            'Body' => $stream,
58        ]));
59
60        return true;
61    }
62
63    public function get(string $path): string
64    {
65        $key = $this->buildKey($path);
66        $result = $this->run(fn(): mixed => $this->client->getObject([
67            'Bucket' => $this->bucket,
68            'Key' => $key,
69        ]));
70
71        if (!$this->hasResultField($result, 'Body')) {
72            throw new StorageException('Invalid S3 response while reading object: ' . $key);
73        }
74
75        return (string) $this->getResultField($result, 'Body');
76    }
77
78    public function delete(string $path): bool
79    {
80        $key = $this->buildKey($path);
81        $this->run(fn(): mixed => $this->client->deleteObject([
82            'Bucket' => $this->bucket,
83            'Key' => $key,
84        ]));
85
86        return true;
87    }
88
89    public function exists(string $path): bool
90    {
91        $key = $this->buildKey($path);
92
93        return (bool) $this->run(fn(): mixed => $this->client->doesObjectExistV2($this->bucket, $key));
94    }
95
96    public function url(string $path): string
97    {
98        $key = $this->buildKey($path);
99        if ($this->publicBaseUrl !== null && $this->publicBaseUrl !== '') {
100            return rtrim(string: $this->publicBaseUrl, characters: '/') . '/' . $key;
101        }
102
103        $result = $this->run(fn(): mixed => $this->client->getObjectUrl($this->bucket, $key));
104
105        return (string) $result;
106    }
107
108    public function size(string $path): int
109    {
110        $key = $this->buildKey($path);
111        $result = $this->run(fn(): mixed => $this->client->headObject([
112            'Bucket' => $this->bucket,
113            'Key' => $key,
114        ]));
115
116        if (!$this->hasResultField($result, 'ContentLength')) {
117            throw new StorageException('Invalid S3 response while reading object size: ' . $key);
118        }
119
120        return (int) $this->getResultField($result, 'ContentLength');
121    }
122
123    /**
124     * @param callable(): mixed $callback
125     */
126    private function run(callable $callback): mixed
127    {
128        try {
129            return $callback();
130        } catch (Throwable $throwable) {
131            throw new StorageException(message: $throwable->getMessage(), previous: $throwable);
132        }
133    }
134
135    private function buildKey(string $path): string
136    {
137        $normalizedPath = ltrim(string: str_replace(search: '\\', replace: '/', subject: trim($path)), characters: '/');
138        if ($normalizedPath === '') {
139            throw new StorageException('Invalid storage path.');
140        }
141
142        $segments = explode('/', $normalizedPath);
143        foreach ($segments as $segment) {
144            if ($segment === '..' || $segment === '') {
145                throw new StorageException('Invalid storage path.');
146            }
147        }
148
149        if ($this->prefix === '') {
150            return $normalizedPath;
151        }
152
153        return trim(string: $this->prefix, characters: '/') . '/' . $normalizedPath;
154    }
155
156    private function hasResultField(mixed $result, string $field): bool
157    {
158        if (is_array($result)) {
159            return array_key_exists($field, $result);
160        }
161
162        if ($result instanceof ArrayAccess) {
163            return $result->offsetExists($field);
164        }
165
166        return false;
167    }
168
169    private function getResultField(mixed $result, string $field): mixed
170    {
171        if (is_array($result)) {
172            return $result[$field] ?? null;
173        }
174
175        if ($result instanceof ArrayAccess) {
176            return $result->offsetGet($field);
177        }
178
179        return null;
180    }
181}