Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
79.37% |
50 / 63 |
|
60.00% |
6 / 10 |
CRAP | |
0.00% |
0 / 1 |
| LocalFileStorage | |
79.37% |
50 / 63 |
|
60.00% |
6 / 10 |
44.16 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| store | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| retrieve | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
4.05 | |||
| exists | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| delete | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
| resolveKey | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
7 | |||
| ensureDirectory | |
50.00% |
2 / 4 |
|
0.00% |
0 / 1 |
6.00 | |||
| writeStreamAtomic | |
57.14% |
12 / 21 |
|
0.00% |
0 / 1 |
13.04 | |||
| generateUuidV4 | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| sanitiseExtension | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Domain\Storage\Service; |
| 6 | |
| 7 | use Psr\Http\Message\StreamFactoryInterface; |
| 8 | use Psr\Http\Message\StreamInterface; |
| 9 | use RuntimeException; |
| 10 | use 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 | */ |
| 29 | final 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 | } |