Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.83% covered (success)
95.83%
23 / 24
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageStorage
95.83% covered (success)
95.83%
23 / 24
80.00% covered (warning)
80.00%
4 / 5
15
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
 store
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 delete
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 storedNames
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 absolutePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace App\Shared\Infrastructure\Storage;
6
7use InvalidArgumentException;
8use Symfony\Component\DependencyInjection\Attribute\Autowire;
9use Symfony\Component\HttpFoundation\File\UploadedFile;
10
11/**
12 * Stores uploaded images on local disk, outside the web root, and hands back the
13 * file name that owning aggregates persist. This is the trust boundary: the type
14 * comes from the file's own bytes (finfo), never from the client-supplied name,
15 * and the caps below are the authoritative gate (nginx/PHP limits only fail
16 * earlier and cheaper).
17 *
18 * ponytail: flat directory, no per-kind subdirs and no media table — one image
19 * per owner is the whole requirement. Shard by name prefix if the file count
20 * ever bites; add a media table when an owner needs many images (receipts).
21 */
22final readonly class ImageStorage
23{
24    private const MAX_BYTES = 2 * 1024 * 1024;
25    private const MAX_PIXELS = 4000;
26
27    /** Mime detected by getimagesize() => extension. SVG is excluded on purpose (it can carry script). */
28    private const EXTENSIONS = [
29        'image/jpeg' => 'jpg',
30        'image/png' => 'png',
31        'image/webp' => 'webp',
32    ];
33
34    public function __construct(
35        #[Autowire('%kernel.project_dir%/var/uploads')]
36        private string $root,
37    ) {
38    }
39
40    /**
41     * @throws InvalidArgumentException on anything that is not an accepted image
42     *
43     * @return string the stored file name, e.g. `ab12…c9.webp`
44     */
45    public function store(UploadedFile $file): string
46    {
47        if (!$file->isValid()) {
48            throw new InvalidArgumentException('Upload failed or exceeded the server limit.');
49        }
50
51        if ($file->getSize() > self::MAX_BYTES) {
52            throw new InvalidArgumentException('Image must not exceed 2 MB.');
53        }
54
55        // One call answers both questions: is this really an image, and how big is
56        // it? Its mime comes from the bytes, so the client-supplied name and
57        // Content-Type never decide the extension we store.
58        $image = getimagesize($file->getPathname());
59        $extension = $image === false ? null : (self::EXTENSIONS[$image['mime']] ?? null);
60        if ($extension === null) {
61            throw new InvalidArgumentException('Image must be a JPEG, PNG or WebP file.');
62        }
63
64        if ($image[0] > self::MAX_PIXELS || $image[1] > self::MAX_PIXELS) {
65            throw new InvalidArgumentException('Image must not exceed 4000x4000 pixels.');
66        }
67
68        $name = bin2hex(random_bytes(16)) . '.' . $extension;
69        $file->move($this->root, $name);
70
71        return $name;
72    }
73
74    /** Removes a stored image; a missing file is not an error (delete is idempotent). */
75    public function delete(?string $name): void
76    {
77        if ($name === null) {
78            return;
79        }
80
81        $path = $this->absolutePath($name);
82        if (is_file($path)) {
83            unlink($path);
84        }
85    }
86
87    /**
88     * Every file currently in the uploads root. Only the orphan report reads the
89     * directory; nothing in a request path ever lists it.
90     *
91     * @return list<string>
92     */
93    public function storedNames(): array
94    {
95        $entries = is_dir($this->root) ? scandir($this->root) : false;
96        if ($entries === false) {
97            return [];
98        }
99
100        // is_file() drops `.` and `..` along with any subdirectory, so no name filter is needed.
101        return array_values(array_filter($entries, fn (string $name): bool => is_file($this->absolutePath($name))));
102    }
103
104    /** basename() so a crafted name can never climb out of the uploads root. */
105    public function absolutePath(string $name): string
106    {
107        return $this->root . '/' . basename($name);
108    }
109}