Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.78% covered (success)
97.78%
44 / 45
87.50% covered (success)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Image
97.78% covered (success)
97.78%
44 / 45
87.50% covered (success)
87.50%
7 / 8
21
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
 setUploadedFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getFileName
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 setFileName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFileExtension
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 isValidMimeType
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 upload
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
9.03
 delete
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3/**
4 * The category image class.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2016-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2016-09-08
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Category;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Core\Exception;
24use Symfony\Component\HttpFoundation\File\UploadedFile;
25
26/**
27 * Class CategoryImage
28 *
29 * @package phpMyFAQ\Category
30 */
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}