Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.00% covered (warning)
84.00%
84 / 100
16.67% covered (danger)
16.67%
1 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageUploadService
83.84% covered (warning)
83.84%
83 / 99
16.67% covered (danger)
16.67%
1 / 6
52.17
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
 uploadImage
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
9
 resolveTempPath
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 detectMimeType
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
7.73
 resolveDimensions
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 generateThumbnail
75.00% covered (warning)
75.00%
27 / 36
0.00% covered (danger)
0.00%
0 / 1
29.56
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Engine\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Exception\ImageUploadException;
12use InvalidArgumentException;
13use Psr\Http\Message\UploadedFileInterface;
14
15/**
16 * Image Upload and Thumbnail Generation Service.
17 *
18 * Handles file validation, storage in public uploads directory,
19 * and high-quality thumbnail generation with aspect ratio preservation.
20 *
21 * @package App\Core\Engine\Application\Service
22 */
23final class ImageUploadService implements ImageUploadServiceInterface
24{
25    public const string MIME_JPEG = 'image/jpeg';
26    public const string MIME_PNG = 'image/png';
27    public const string MIME_WEBP = 'image/webp';
28    public const string MIME_GIF = 'image/gif';
29
30    private const int MAX_FILE_SIZE = 10485760; // 10 MB
31    private const int THUMB_MAX_WIDTH = 320;
32    private const int THUMB_MAX_HEIGHT = 320;
33
34    private const array ALLOWED_MIME_TYPES = [
35        self::MIME_JPEG => 'jpg',
36        self::MIME_PNG  => 'png',
37        self::MIME_WEBP => 'webp',
38        self::MIME_GIF  => 'gif',
39    ];
40
41    /**
42     * ImageUploadService constructor.
43     *
44     * @param string $publicPath Base public web root filesystem path.
45     */
46    public function __construct(
47        private readonly string $publicPath,
48    ) {
49    }
50
51    /**
52     * Processes and stores an uploaded image file, generating a thumbnail.
53     *
54     * @param UploadedFileInterface $file Uploaded PSR-7 file instance.
55     * @param string $subfolder Optional relative subdirectory (default 'media').
56     * @return array{
57     *     name: string,
58     *     path: string,
59     *     thumb: string,
60     *     size: int,
61     *     mime: string,
62     *     width: int,
63     *     height: int
64     * } Image metadata with public URLs.
65     */
66    public function uploadImage(UploadedFileInterface $file, string $subfolder = 'media'): array
67    {
68        if ($file->getError() !== UPLOAD_ERR_OK) {
69            $code = $file->getError();
70            throw new ImageUploadException("Failed to upload file due to an error (code {$code}).");
71        }
72
73        $size = (int) $file->getSize();
74        if ($size <= 0 || $size > self::MAX_FILE_SIZE) {
75            throw new InvalidArgumentException('File size exceeds the maximum allowed limit of 10 MB.');
76        }
77
78        $tempPath = $this->resolveTempPath($file);
79        $mimeType = $this->detectMimeType($tempPath);
80
81        if (!isset(self::ALLOWED_MIME_TYPES[$mimeType])) {
82            throw new InvalidArgumentException(
83                "Unsupported image format: {$mimeType}. Allowed formats: JPEG, PNG, WebP, GIF."
84            );
85        }
86
87        $extension = self::ALLOWED_MIME_TYPES[$mimeType];
88        $dateFolder = date('Y/m');
89        $relativeDir = 'uploads/' . trim($subfolder, '/') . '/' . $dateFolder;
90        $targetDir = rtrim($this->publicPath, '/\\') . '/' . $relativeDir;
91
92        if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
93            throw new ImageUploadException("Failed to create upload target directory: {$targetDir}");
94        }
95
96        $baseName = 'img_' . bin2hex(random_bytes(8));
97        $mainFileName = "{$baseName}.{$extension}";
98        $thumbFileName = "thumb_{$baseName}.{$extension}";
99
100        $mainAbsolutePath = "{$targetDir}/{$mainFileName}";
101        $thumbAbsolutePath = "{$targetDir}/{$thumbFileName}";
102
103        $file->moveTo($mainAbsolutePath);
104
105        [$origWidth, $origHeight] = $this->resolveDimensions($mainAbsolutePath);
106        $this->generateThumbnail($mainAbsolutePath, $thumbAbsolutePath, $mimeType, $origWidth, $origHeight);
107
108        $clientName = (string) $file->getClientFilename();
109        $safeClientName = (string) preg_replace('/[^\w\s\.\-\(\)]/u', '_', $clientName);
110
111        return [
112            'name'   => $safeClientName !== '' ? $safeClientName : $mainFileName,
113            'path'   => '/' . $relativeDir . '/' . $mainFileName,
114            'thumb'  => '/' . $relativeDir . '/' . $thumbFileName,
115            'size'   => $size,
116            'mime'   => $mimeType,
117            'width'  => $origWidth,
118            'height' => $origHeight,
119        ];
120    }
121
122    /**
123     * Resolves temporary filepath from uploaded file stream.
124     */
125    private function resolveTempPath(UploadedFileInterface $file): string
126    {
127        $uri = $file->getStream()->getMetadata('uri');
128        if (is_string($uri) && file_exists($uri)) {
129            return $uri;
130        }
131
132        $tmpFile = tempnam(sys_get_temp_dir(), 'upl_');
133        if ($tmpFile === false) {
134            throw new ImageUploadException('Failed to allocate temporary file for image validation.');
135        }
136
137        $file->getStream()->rewind();
138        file_put_contents($tmpFile, $file->getStream()->getContents());
139        $file->getStream()->rewind();
140
141        return $tmpFile;
142    }
143
144    /**
145     * Accurately detects image MIME type via PHP finfo or fallback.
146     */
147    private function detectMimeType(string $path): string
148    {
149        if (function_exists('finfo_open')) {
150            $finfo = finfo_open(FILEINFO_MIME_TYPE);
151            if ($finfo !== false) {
152                $mime = finfo_file($finfo, $path);
153                finfo_close($finfo);
154                if (is_string($mime) && $mime !== '') {
155                    return strtolower($mime);
156                }
157            }
158        }
159
160        $imageInfo = @getimagesize($path);
161        if (is_array($imageInfo)) {
162            return strtolower((string) $imageInfo['mime']);
163        }
164
165        return 'application/octet-stream';
166    }
167
168    /**
169     * Resolves pixel width and height of an image file.
170     *
171     * @return array{0: int, 1: int} Width and height.
172     */
173    private function resolveDimensions(string $path): array
174    {
175        $info = @getimagesize($path);
176        if (is_array($info)) {
177            return [(int) $info[0], (int) $info[1]];
178        }
179
180        return [0, 0];
181    }
182
183    /**
184     * Generates a scaled thumbnail while preserving original aspect ratio.
185     */
186    private function generateThumbnail(
187        string $sourcePath,
188        string $destPath,
189        string $mimeType,
190        int $origWidth,
191        int $origHeight,
192    ): void {
193        if ($origWidth <= 0 || $origHeight <= 0 || !extension_loaded('gd')) {
194            copy($sourcePath, $destPath);
195            return;
196        }
197
198        $ratio = min(self::THUMB_MAX_WIDTH / $origWidth, self::THUMB_MAX_HEIGHT / $origHeight);
199        $newWidth = (int) max(1, round($origWidth * min(1.0, $ratio)));
200        $newHeight = (int) max(1, round($origHeight * min(1.0, $ratio)));
201
202        $srcImage = match ($mimeType) {
203            self::MIME_JPEG => @imagecreatefromjpeg($sourcePath),
204            self::MIME_PNG  => @imagecreatefrompng($sourcePath),
205            self::MIME_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($sourcePath) : null,
206            self::MIME_GIF  => @imagecreatefromgif($sourcePath),
207            default         => null,
208        };
209
210        if ($srcImage === null || $srcImage === false) {
211            copy($sourcePath, $destPath);
212            return;
213        }
214
215        $thumbImage = imagecreatetruecolor($newWidth, $newHeight);
216        if ($thumbImage === false) {
217            imagedestroy($srcImage);
218            copy($sourcePath, $destPath);
219            return;
220        }
221
222        if ($mimeType === self::MIME_PNG || $mimeType === self::MIME_WEBP || $mimeType === self::MIME_GIF) {
223            imagealphablending($thumbImage, false);
224            imagesavealpha($thumbImage, true);
225        }
226
227        imagecopyresampled($thumbImage, $srcImage, 0, 0, 0, 0, $newWidth, $newHeight, $origWidth, $origHeight);
228
229        match ($mimeType) {
230            self::MIME_JPEG => imagejpeg($thumbImage, $destPath, 85),
231            self::MIME_PNG  => imagepng($thumbImage, $destPath, 6),
232            self::MIME_WEBP => function_exists('imagewebp')
233                ? imagewebp($thumbImage, $destPath, 85)
234                : imagejpeg($thumbImage, $destPath, 85),
235            self::MIME_GIF  => imagegif($thumbImage, $destPath),
236            default         => copy($sourcePath, $destPath),
237        };
238
239        imagedestroy($thumbImage);
240        imagedestroy($srcImage);
241    }
242}