Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.37% covered (warning)
79.37%
50 / 63
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
LocalFileStorage
79.37% covered (warning)
79.37%
50 / 63
60.00% covered (warning)
60.00%
6 / 10
44.16
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 store
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 retrieve
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 exists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 resolveKey
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 ensureDirectory
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
6.00
 writeStreamAtomic
57.14% covered (warning)
57.14%
12 / 21
0.00% covered (danger)
0.00%
0 / 1
13.04
 generateUuidV4
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 sanitiseExtension
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Storage\Service;
6
7use Psr\Http\Message\StreamFactoryInterface;
8use Psr\Http\Message\StreamInterface;
9use RuntimeException;
10use Throwable;
11
12/**
13 * Local-filesystem implementation of {@see FileStorageInterface}.
14 *
15 * Files are stored under a configured root with a hash-prefixed layout:
16 *   {root}/ab/cd/abcd1234-...uuid.ext
17 * The two-level prefix keeps any one directory from accumulating tens of
18 * thousands of entries.
19 *
20 * Filenames are random v4 UUIDs — the user-supplied filename is never used
21 * for path construction. The optional extension is sanitised to
22 * `[A-Za-z0-9]{1..10}` and is purely cosmetic for ops listings; it is not
23 * used to dispatch handlers or set Content-Type.
24 *
25 * Writes go through a temp file + rename, so a partially-written file is
26 * never visible under its final key. Files are chmod 0600, directories
27 * 0700.
28 */
29final class LocalFileStorage implements FileStorageInterface
30{
31    public function __construct(
32        private readonly string $rootPath,
33        private readonly StreamFactoryInterface $streamFactory,
34    ) {
35        if ($this->rootPath === '') {
36            throw new RuntimeException('LocalFileStorage requires a non-empty root path.');
37        }
38    }
39
40    public function store(StreamInterface $stream, string $extension = ''): string
41    {
42        $uuid = $this->generateUuidV4();
43        $extPart = $this->sanitiseExtension($extension);
44        $key = sprintf('%s/%s/%s%s', substr($uuid, 0, 2), substr($uuid, 2, 2), $uuid, $extPart);
45
46        $absolutePath = $this->resolveKey($key);
47        $this->ensureDirectory(dirname($absolutePath));
48        $this->writeStreamAtomic($absolutePath, $stream);
49
50        return $key;
51    }
52
53    public function retrieve(string $key): StreamInterface
54    {
55        $path = $this->resolveKey($key);
56
57        if (!is_file($path) || !is_readable($path)) {
58            throw new RuntimeException(sprintf('Storage key not readable: %s', $key));
59        }
60
61        $resource = fopen($path, 'rb');
62        if ($resource === false) {
63            throw new RuntimeException(sprintf('Failed to open storage key: %s', $key));
64        }
65
66        return $this->streamFactory->createStreamFromResource($resource);
67    }
68
69    public function exists(string $key): bool
70    {
71        return is_file($this->resolveKey($key));
72    }
73
74    public function delete(string $key): void
75    {
76        $path = $this->resolveKey($key);
77        if (!is_file($path)) {
78            return;
79        }
80        if (!@unlink($path)) {
81            throw new RuntimeException(sprintf('Failed to delete storage key: %s', $key));
82        }
83    }
84
85    /**
86     * Resolve a key to an absolute path under the root, refusing any key
87     * that could escape the storage root via traversal.
88     * @param string $key
89     */
90    private function resolveKey(string $key): string
91    {
92        if (
93            $key === ''
94            || str_contains($key, '..')
95            || str_contains($key, "\0")
96            || str_starts_with($key, '/')
97            || str_starts_with($key, '\\')
98            || preg_match('/^[A-Za-z]:/', $key) === 1
99        ) {
100            throw new RuntimeException(sprintf('Refusing unsafe storage key: %s', $key));
101        }
102
103        return $this->rootPath . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $key);
104    }
105
106    private function ensureDirectory(string $dir): void
107    {
108        if (is_dir($dir)) {
109            return;
110        }
111
112        if (!@mkdir($dir, 0o700, true) && !is_dir($dir)) {
113            throw new RuntimeException(sprintf('Failed to create directory: %s', $dir));
114        }
115    }
116
117    private function writeStreamAtomic(string $path, StreamInterface $stream): void
118    {
119        $tmpPath = $path . '.tmp.' . bin2hex(random_bytes(4));
120
121        $out = @fopen($tmpPath, 'wb');
122        if ($out === false) {
123            throw new RuntimeException(sprintf('Failed to open temp file: %s', $tmpPath));
124        }
125
126        try {
127            if ($stream->isSeekable()) {
128                $stream->rewind();
129            }
130
131            while (!$stream->eof()) {
132                $chunk = $stream->read(8192);
133                if ($chunk === '') {
134                    break;
135                }
136                if (fwrite($out, $chunk) === false) {
137                    throw new RuntimeException(sprintf('Write failed: %s', $tmpPath));
138                }
139            }
140        } catch (Throwable $e) {
141            fclose($out);
142            @unlink($tmpPath);
143            throw $e;
144        }
145
146        fclose($out);
147        @chmod($tmpPath, 0o600);
148
149        if (!@rename($tmpPath, $path)) {
150            @unlink($tmpPath);
151            throw new RuntimeException(sprintf('Failed to finalise file: %s', $path));
152        }
153    }
154
155    private function generateUuidV4(): string
156    {
157        $bytes = random_bytes(16);
158        $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
159        $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
160
161        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
162    }
163
164    private function sanitiseExtension(string $extension): string
165    {
166        $extension = ltrim($extension, '.');
167        if ($extension === '' || preg_match('/^[A-Za-z0-9]{1,10}$/', $extension) !== 1) {
168            return '';
169        }
170
171        return '.' . strtolower($extension);
172    }
173}