Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.93% covered (success)
83.93%
94 / 112
0.00% covered (danger)
0.00%
0 / 1
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageController
83.93% covered (success)
83.93%
94 / 112
0.00% covered (danger)
0.00%
0 / 1
20.50
0.00% covered (danger)
0.00%
0 / 1
 upload
83.93% covered (success)
83.93%
94 / 112
0.00% covered (danger)
0.00%
0 / 1
20.50
1<?php
2
3/**
4 * The Admin Image Controller
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 2023-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     2023-10-26
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Administration\Api;
21
22use DateTime;
23use phpMyFAQ\Controller\AbstractController;
24use phpMyFAQ\Core\Exception;
25use phpMyFAQ\Enums\PermissionType;
26use phpMyFAQ\Helper\SvgSanitizer as SvgSanitizer;
27use phpMyFAQ\Session\Token;
28use phpMyFAQ\Translation;
29use Symfony\Component\HttpFoundation\File\UploadedFile;
30use Symfony\Component\HttpFoundation\JsonResponse;
31use Symfony\Component\HttpFoundation\Request;
32use Symfony\Component\HttpFoundation\Response;
33use Symfony\Component\Routing\Attribute\Route;
34
35final class ImageController extends AbstractController
36{
37    private const array ALLOWED_MIME_TYPES = [
38        'gif' => 'image/gif',
39        'jpg' => 'image/jpeg',
40        'jpeg' => 'image/jpeg',
41        'png' => 'image/png',
42        'webp' => 'image/webp',
43        'mov' => 'video/quicktime',
44        'mp4' => 'video/mp4',
45        'svg' => 'image/svg+xml',
46        'webm' => 'video/webm',
47    ];
48
49    /**
50     * @throws Exception|\Exception
51     */
52    #[Route(path: 'content/images', name: 'admin.api.content.images', methods: ['POST'])]
53    public function upload(Request $request): JsonResponse
54    {
55        $this->userHasPermission(PermissionType::FAQ_EDIT);
56
57        $uploadDir = PMF_CONTENT_DIR . '/user/images/';
58        $validFileExtensions = array_keys(self::ALLOWED_MIME_TYPES);
59        $timestamp = time();
60
61        if (!Token::getInstance($this->session)->verifyToken('pmf-csrf-token', $request->query->get('csrf'))) {
62            return $this->json([
63                'success' => false,
64                'data' => ['code' => Response::HTTP_UNAUTHORIZED],
65                'messages' => [Translation::get(key: 'msgNoPermission')],
66            ], Response::HTTP_UNAUTHORIZED);
67        }
68
69        $files = $request->files->get('files');
70        if (!is_array($files)) {
71            $files = $files === null ? [] : [$files];
72        }
73
74        $uploadedFiles = [];
75        $headers = [];
76        foreach ($files as $file) {
77            if (!$file instanceof UploadedFile || !$file->isValid()) {
78                continue;
79            }
80
81            $httpOrigin = $request->server->get('HTTP_ORIGIN');
82            if ($httpOrigin !== null && (string) $httpOrigin . '/' === $this->configuration->getDefaultUrl()) {
83                $headers = ['Access-Control-Allow-Origin' => (string) $httpOrigin];
84            }
85
86            // Sanitize input
87            if (preg_match("/([^\w\s\d\-_~,;:\[\]\(\).])|([\.]{2,})/", (string) $file->getClientOriginalName())) {
88                return $this->json(
89                    [
90                        'success' => false,
91                        'data' => ['code' => Response::HTTP_BAD_REQUEST],
92                        'messages' => ['Data contains invalid characters'],
93                    ],
94                    Response::HTTP_BAD_REQUEST,
95                    $headers,
96                );
97            }
98
99            // Verify extension
100            if (!in_array(
101                strtolower((string) $file->getClientOriginalExtension()),
102                $validFileExtensions,
103                strict: true,
104            )) {
105                return $this->json(
106                    [
107                        'success' => false,
108                        'data' => ['code' => Response::HTTP_BAD_REQUEST],
109                        'messages' => ['File extension not allowed'],
110                    ],
111                    Response::HTTP_BAD_REQUEST,
112                    $headers,
113                );
114            }
115
116            // Accept upload if there was no origin or if it is an accepted origin
117            $fileName = $timestamp . '_' . $file->getClientOriginalName();
118            $fileName = str_replace(' ', replace: '_', subject: $fileName);
119            $file->move($uploadDir, $fileName);
120
121            $filePath = $uploadDir . $fileName;
122            $fileExtension = strtolower((string) $file->getClientOriginalExtension());
123
124            // Validate actual MIME type matches the claimed extension
125            $detectedMime = mime_content_type($filePath);
126            $expectedMime = self::ALLOWED_MIME_TYPES[$fileExtension] ?? null;
127
128            if ($detectedMime === false || $expectedMime === null || $detectedMime !== $expectedMime) {
129                if (file_exists($filePath)) {
130                    unlink($filePath);
131                }
132
133                return $this->json(
134                    [
135                        'success' => false,
136                        'data' => ['code' => Response::HTTP_BAD_REQUEST],
137                        'messages' => ['File content does not match the file extension'],
138                    ],
139                    Response::HTTP_BAD_REQUEST,
140                    $headers,
141                );
142            }
143
144            if ($fileExtension === 'svg') {
145                $sanitizer = new SvgSanitizer();
146
147                if (!$sanitizer->isSafe($filePath)) {
148                    $this->configuration
149                        ->getLogger()
150                        ->info(sprintf(
151                            'Potentially malicious SVG upload detected: %s by user %d',
152                            $fileName,
153                            $this->currentUser->getUserId(),
154                        ));
155
156                    if (!$sanitizer->sanitize($filePath)) {
157                        if (file_exists($filePath)) {
158                            unlink($filePath);
159                        }
160                        $this->configuration
161                            ->getLogger()
162                            ->info(sprintf('SVG sanitization failed, file deleted: %s', $fileName));
163                        continue;
164                    }
165                }
166            }
167
168            // Add to the list of uploaded files
169            $uploadedFiles[] = $fileName;
170        }
171
172        // Build full URLs for Jodit editor
173        $fileUrls = array_map(
174            fn($file) => $this->configuration->getDefaultUrl() . 'content/user/images/' . $file,
175            $uploadedFiles,
176        );
177
178        $response = [
179            'success' => true,
180            'time' => new DateTime()->format('Y-m-d H:i:s'),
181            'data' => [
182                'messages' => ['Files uploaded successfully'],
183                'files' => $fileUrls, // For Jodit uploader
184                'isImages' => array_map(
185                    static fn($file) => !in_array(
186                        pathinfo($file, PATHINFO_EXTENSION),
187                        ['mov', 'mp4', 'webm'],
188                        strict: true,
189                    ),
190                    $uploadedFiles,
191                ),
192                'sources' => [
193                    [
194                        'baseurl' => $this->configuration->getDefaultUrl(),
195                        'path' => 'content/user/images/',
196                        'files' => $uploadedFiles,
197                        'name' => 'default',
198                    ],
199                ],
200                'code' => 220,
201            ],
202        ];
203
204        return $this->json($response, Response::HTTP_OK, $headers);
205    }
206}