Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
FundImageStorage
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 7
992
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fromDefaultProjectRoot
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 storeHeroImage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 storeManagerPhoto
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 deleteManagedFileIfReplaced
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 store
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
110
 resolveAbsoluteManagedPath
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
132
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Funds\Service;
6
7use InvalidArgumentException;
8use Psr\Http\Message\UploadedFileInterface;
9use RuntimeException;
10
11/**
12 * Stores fund hero and manager images under public/uploads/funds/{hero|manager}/.
13 */
14final class FundImageStorage
15{
16    private const MAX_BYTES = 5_242_880; // 5 MiB
17
18    private const MANAGED_PREFIX = '/uploads/funds/';
19
20    /** @var array<string, string> mime type (lowercase) => file extension */
21    private const ALLOWED = [
22        'image/jpeg' => 'jpg',
23        'image/png' => 'png',
24        'image/webp' => 'webp',
25        'image/gif' => 'gif',
26    ];
27
28    public function __construct(
29        private readonly string $publicRootPath,
30    ) {}
31
32    public static function fromDefaultProjectRoot(): self
33    {
34        $publicDir = dirname(__DIR__, 4) . '/public';
35
36        return new self($publicDir);
37    }
38
39    /**
40     * @return string Public path beginning with /uploads/funds/hero/…
41     */
42    public function storeHeroImage(UploadedFileInterface $file): string
43    {
44        return $this->store($file, 'hero');
45    }
46
47    /**
48     * @return string Public path beginning with /uploads/funds/manager/…
49     */
50    public function storeManagerPhoto(UploadedFileInterface $file): string
51    {
52        return $this->store($file, 'manager');
53    }
54
55    /**
56     * After a successful upload, remove the previous file if it was stored under our uploads tree.
57     */
58    public function deleteManagedFileIfReplaced(?string $previousPublicPath, string $newPublicPath): void
59    {
60        if ($previousPublicPath === null || $previousPublicPath === '') {
61            return;
62        }
63
64        $prev = trim($previousPublicPath);
65        $new = trim($newPublicPath);
66        if ($prev === '' || $prev === $new) {
67            return;
68        }
69
70        $absolute = $this->resolveAbsoluteManagedPath($prev);
71        if ($absolute !== null) {
72            @unlink($absolute);
73        }
74    }
75
76    private function store(UploadedFileInterface $file, string $subdir): string
77    {
78        if ($file->getError() !== UPLOAD_ERR_OK) {
79            throw new InvalidArgumentException('Image upload failed');
80        }
81
82        if ($file->getSize() !== null && $file->getSize() > self::MAX_BYTES) {
83            throw new InvalidArgumentException('Image must be 5 MB or smaller');
84        }
85
86        $mime = $file->getClientMediaType();
87        if ($mime === '' || $mime === null) {
88            throw new InvalidArgumentException('Could not determine image type');
89        }
90
91        $mimeLower = strtolower($mime);
92        if (!isset(self::ALLOWED[$mimeLower])) {
93            throw new InvalidArgumentException('Image must be JPEG, PNG, WebP, or GIF');
94        }
95
96        $ext = self::ALLOWED[$mimeLower];
97        $relativeDir = '/uploads/funds/' . $subdir;
98        $absoluteDir = $this->publicRootPath . $relativeDir;
99
100        if (!is_dir($absoluteDir) && !mkdir($absoluteDir, 0755, true) && !is_dir($absoluteDir)) {
101            throw new RuntimeException('Could not create upload directory');
102        }
103
104        $basename = bin2hex(random_bytes(16)) . '.' . $ext;
105        $targetPath = $absoluteDir . '/' . $basename;
106
107        $file->moveTo($targetPath);
108
109        return $relativeDir . '/' . $basename;
110    }
111
112    private function resolveAbsoluteManagedPath(string $publicPath): ?string
113    {
114        $trimmed = trim($publicPath);
115        if ($trimmed === '' || str_contains($trimmed, '..')) {
116            return null;
117        }
118
119        $lower = strtolower($trimmed);
120        if (str_starts_with($lower, 'http://') || str_starts_with($lower, 'https://')) {
121            return null;
122        }
123
124        if (!str_starts_with($trimmed, self::MANAGED_PREFIX)) {
125            return null;
126        }
127
128        $relativeFromPublic = ltrim($trimmed, '/');
129        $full = $this->publicRootPath . '/' . $relativeFromPublic;
130
131        $uploadsRoot = realpath($this->publicRootPath . '/uploads/funds');
132        if ($uploadsRoot === false) {
133            return null;
134        }
135
136        $realFile = realpath($full);
137        if ($realFile === false || !is_file($realFile)) {
138            return null;
139        }
140
141        $uploadsRootNormalized = rtrim(str_replace('\\', '/', $uploadsRoot), '/');
142        $realFileNormalized = str_replace('\\', '/', $realFile);
143
144        if (!str_starts_with($realFileNormalized, $uploadsRootNormalized . '/') && $realFileNormalized !== $uploadsRootNormalized) {
145            return null;
146        }
147
148        return $realFile;
149    }
150}