Lines 97.77% 44 / 45
Methods 87.50% 7 / 8
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 setUploadedFile 100.00% 4 / 4 100.00% 1 / 1 2
 getFileName 100.00% 8 / 8 100.00% 1 / 1 2
 setFileName 100.00% 2 / 2 100.00% 1 / 1 1
 getFileExtension 100.00% 7 / 7 100.00% 1 / 1 1
 isValidMimeType 100.00% 2 / 2 100.00% 1 / 1 1
 upload 92.85% 13 / 14 0.00% 0 / 1 9.03
 delete 100.00% 7 / 7 100.00% 1 / 1 4
31class Image
32{
33    private const string UPLOAD_DIR = PMF_CONTENT_DIR . '/user/images/';
34
35    private bool $isUpload = false;
36
37    private UploadedFile $uploadedFile;
38
39    private string $fileName = '';
40
41    /**
42     * Constructor.
43     *
44     * @param Configuration $configuration Configuration object
45     */
46    public function __construct(
47        private readonly Configuration $configuration,
48    ) {
49    }
50
51    /**
52     * Sets the uploaded file
53     */
54    public function setUploadedFile(UploadedFile $uploadedFile): Image
55    {
56        if ($uploadedFile->isValid()) {
57            $this->isUpload = true;
58        }
59
60        $this->uploadedFile = $uploadedFile;
61
62        return $this;
63    }
64
65    /**
66     * Returns the filename for the given category ID and language.
67     */
68    public function getFileName(int $categoryId, string $categoryName): string
69    {
70        if ($this->isUpload) {
71            $this->setFileName(sprintf(
72                'category-%d-%s.%s',
73                $categoryId,
74                $categoryName,
75                $this->getFileExtension((string) $this->uploadedFile->getMimeType()),
76            ));
77        }
78
79        return $this->fileName;
80    }
81
82    /**
83     * Returns the filename.
84     */
85    public function setFileName(string $fileName): Image
86    {
87        $this->fileName = $fileName;
88
89        return $this;
90    }
91
92    /**
93     * Returns the image file extension from a given MIME type.
94     */
95    private function getFileExtension(string $mimeType): string
96    {
97        $mapping = [
98            'image/gif' => 'gif',
99            'image/jpeg' => 'jpg',
100            'image/png' => 'png',
101            'image/webp' => 'webp',
102        ];
103
104        return $mapping[$mimeType] ?? 'png';
105    }
106
107    /**
108     * Checks for valid image MIME types, returns true if valid
109     */
110    private function isValidMimeType(string $contentType): bool
111    {
112        $types = ['image/jpeg', 'image/gif', 'image/png', 'image/webp'];
113        return in_array($contentType, $types, strict: true);
114    }
115
116    /**
117     * Uploads the current file and moves it into the images/ folder.
118     *
119     * @throws Exception
120     */
121    public function upload(): bool
122    {
123        if (
124            $this->isUpload
125            && $this->uploadedFile->isValid()
126            && (int) $this->uploadedFile->getSize() < (int) $this->configuration->get(item: 'records.maxAttachmentSize')
127        ) {
128            if (false === $this->uploadedFile->getSize()) {
129                throw new Exception('Cannot detect image size');
130            }
131
132            if (!$this->isValidMimeType($this->uploadedFile->getClientMimeType())) {
133                throw new Exception('Image MIME type validation failed.');
134            }
135
136            // Ensure destination directory exists
137            /* @mago-expect lint:no-error-control-operator - mkdir may race a concurrent request; the re-check handles it */
138            if (
139                !is_dir(self::UPLOAD_DIR)
140                && (!@mkdir(self::UPLOAD_DIR, permissions: 0o775, recursive: true) && !is_dir(self::UPLOAD_DIR))
141            ) {
142                throw new Exception('Upload directory does not exist and could not be created: ' . self::UPLOAD_DIR);
143            }
144
145            $this->uploadedFile->move(self::UPLOAD_DIR, $this->fileName);
146
147            // move() has consumed the PHP upload temp file. phpMyFAQ rebuilds
148            // the request from globals in many places via
149            // Request::createFromGlobals(); each rebuild reconstructs an
150            // UploadedFile from $_FILES, and Symfony's File constructor throws
151            // FileNotFoundException once that temp file no longer exists.
152            // Drop the consumed entry so later rebuilds in this process stay
153            // valid.
154            unset($_FILES['image']);
155
156            return true;
157        }
158
159        throw new Exception('Uploaded image is too big');
160    }
161
162    /**
163     * Deletes the current file, returns true if no file is available.
164     *
165     * The stored file name is treated as untrusted: only a plain file name
166     * located directly inside the upload directory may be removed. Any value
167     * containing path separators or traversal sequences (e.g. "../") is
168     * rejected to prevent deleting arbitrary files outside UPLOAD_DIR.
169     */
170    public function delete(): bool
171    {
172        if ($this->fileName === '') {
173            return true;
174        }
175
176        // Reject anything that is not a bare file name (no directories, no "..").
177        if (basename($this->fileName) !== $this->fileName) {
178            return false;
179        }
180
181        if (is_file(self::UPLOAD_DIR . $this->fileName)) {
182            return unlink(self::UPLOAD_DIR . $this->fileName);
183        }
184
185        return true;
186    }
187}