Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.45% covered (success)
95.45%
147 / 154
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserController
95.45% covered (success)
95.45%
147 / 154
60.00% covered (warning)
60.00%
3 / 5
53
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
 updateData
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
1 / 1
22
 exportUserData
88.89% covered (success)
88.89%
32 / 36
0.00% covered (danger)
0.00%
0 / 1
8.09
 requestUserRemoval
93.75% covered (success)
93.75%
45 / 48
0.00% covered (danger)
0.00%
0 / 1
16.06
 removeTwofactorConfig
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3/**
4 * The User 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 2024-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     2024-03-02
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Frontend\Api;
21
22use phpMyFAQ\Controller\AbstractController;
23use phpMyFAQ\Core\Exception;
24use phpMyFAQ\Filter;
25use phpMyFAQ\Mail;
26use phpMyFAQ\Session\Token;
27use phpMyFAQ\StopWords;
28use phpMyFAQ\Translation;
29use phpMyFAQ\User\TwoFactor;
30use RobThree\Auth\TwoFactorAuthException;
31use Symfony\Component\HttpFoundation\BinaryFileResponse;
32use Symfony\Component\HttpFoundation\JsonResponse;
33use Symfony\Component\HttpFoundation\Request;
34use Symfony\Component\HttpFoundation\Response;
35use Symfony\Component\HttpFoundation\ResponseHeaderBag;
36use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
37use Symfony\Component\Routing\Attribute\Route;
38use ZipArchive;
39
40final class UserController extends AbstractController
41{
42    public function __construct(
43        private readonly StopWords $stopWords,
44        private readonly Mail $mailer,
45    ) {
46        parent::__construct();
47    }
48
49    /**
50     * @throws \Exception
51     */
52    #[Route(path: 'user/data/update', name: 'api.private.user.update', methods: ['PUT'])]
53    public function updateData(Request $request): JsonResponse
54    {
55        $this->userIsAuthenticated();
56
57        $data = $this->getJsonObject($request);
58
59        $csrfToken = Filter::filterVar($data->{'pmf-csrf-token'} ?? null, FILTER_SANITIZE_SPECIAL_CHARS);
60
61        if (!Token::getInstance($this->session)->verifyToken('ucp', $csrfToken)) {
62            return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')], Response::HTTP_UNAUTHORIZED);
63        }
64
65        $userId = Filter::filterVar($data->userid ?? null, FILTER_VALIDATE_INT);
66        $userName = trim(strip_tags((string) ($data->name ?? '')));
67        $email = Filter::filterEmail($data->email ?? null);
68        $isVisible = Filter::filterVar($data->{'is_visible'} ?? null, FILTER_SANITIZE_SPECIAL_CHARS);
69        $password = trim((string) Filter::filterVar($data->faqpassword ?? null, FILTER_SANITIZE_SPECIAL_CHARS));
70        $confirm = trim((string) Filter::filterVar($data->faqpassword_confirm ?? null, FILTER_SANITIZE_SPECIAL_CHARS));
71        $twoFactorEnabled = Filter::filterVar($data->twofactor_enabled ?? 'off', FILTER_SANITIZE_SPECIAL_CHARS);
72        $secret = Filter::filterVar($data->secret ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
73
74        $isAzureAdUser = $this->currentUser->getUserAuthSource() === 'azure';
75        $isWebAuthnUser = $this->currentUser->getUserAuthSource() === 'webauthn';
76
77        if ($userId !== $this->currentUser->getUserId()) {
78            return $this->json(['error' => 'User ID mismatch!'], Response::HTTP_BAD_REQUEST);
79        }
80
81        $success = false;
82        if (!$isAzureAdUser) {
83            // The password is only changed when the user actually entered one;
84            // leaving both fields blank keeps the current password.
85            $changePassword = $password !== '' || $confirm !== '';
86
87            if ($changePassword) {
88                if (!hash_equals($password, $confirm)) {
89                    return $this->json([
90                        'error' => Translation::get('ad_user_error_passwordsDontMatch'),
91                    ], Response::HTTP_CONFLICT);
92                }
93
94                if ((strlen($password) <= 7 || strlen($confirm) <= 7) && !$isWebAuthnUser) {
95                    return $this->json(['error' => Translation::get(key: 'ad_passwd_fail')], Response::HTTP_CONFLICT);
96                }
97            }
98
99            $userData = [
100                'display_name' => $userName,
101                'is_visible' => $isVisible === 'on' ? 1 : 0,
102            ];
103            if (!$isWebAuthnUser) {
104                $userData['email'] = is_string($email) ? $email : '';
105                $userData['twofactor_enabled'] = $twoFactorEnabled === 'on' ? 1 : 0;
106            }
107
108            $success = $this->currentUser->setUserData($userData);
109
110            if ($changePassword) {
111                foreach ($this->currentUser->getAuthContainer() as $authDriver) {
112                    if ($authDriver->disableReadOnly()) {
113                        continue;
114                    }
115
116                    if (!$authDriver->update($this->currentUser->getLogin(), $password)) {
117                        return $this->json(['error' => $authDriver->getErrors()], Response::HTTP_BAD_REQUEST);
118                    }
119
120                    $success = true;
121                }
122            }
123        }
124
125        if ($isAzureAdUser) {
126            $userData = [
127                'is_visible' => $isVisible === 'on' ? 1 : 0,
128                'twofactor_enabled' => $twoFactorEnabled === 'on' ? 1 : 0,
129                'secret' => $secret,
130            ];
131
132            $success = $this->currentUser->setUserData($userData);
133        }
134
135        if ($success) {
136            return $this->json(['success' => Translation::get(key: 'ad_entry_savedsuc')], Response::HTTP_OK);
137        }
138
139        return $this->json(['error' => Translation::get(key: 'ad_entry_savedfail')], Response::HTTP_BAD_REQUEST);
140    }
141
142    /**
143     * Export userdata of the currently logged-in user as a ZIP file.
144     *
145     * @throws \Exception
146     */
147    #[Route(path: 'user/data/export', name: 'api.private.user.data.export', methods: ['POST'])]
148    public function exportUserData(Request $request): Response
149    {
150        $this->userIsAuthenticated();
151
152        $inputBag = $request->getPayload();
153
154        $csrfToken = Filter::filterVar($inputBag->get('pmf-csrf-token'), FILTER_SANITIZE_SPECIAL_CHARS);
155        $userIdInput = Filter::filterVar($inputBag->get('userid') ?? null, FILTER_VALIDATE_INT);
156
157        if (!Token::getInstance($this->session)->verifyToken('export-userdata', $csrfToken)) {
158            return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')], Response::HTTP_UNAUTHORIZED);
159        }
160
161        if (null !== $userIdInput && $userIdInput !== $this->currentUser->getUserId()) {
162            return $this->json(['error' => 'User ID mismatch!'], Response::HTTP_BAD_REQUEST);
163        }
164
165        if (!class_exists(ZipArchive::class)) {
166            return $this->json(['error' => 'ZIP extension not available.'], Response::HTTP_INTERNAL_SERVER_ERROR);
167        }
168
169        $userData = [
170            'user_id' => $this->currentUser->getUserId(),
171            'last_modified' => (string) ($this->currentUser->getUserData('last_modified') ?? ''),
172            'display_name' => (string) ($this->currentUser->getUserData('display_name') ?? ''),
173            'email' => (string) ($this->currentUser->getUserData('email') ?? ''),
174            'is_visible' => (int) ($this->currentUser->getUserData('is_visible') ?? 0),
175            'twofactor_enabled' => (int) ($this->currentUser->getUserData('twofactor_enabled') ?? 0),
176            'secret' => (string) ($this->currentUser->getUserData('secret') ?? ''),
177        ];
178
179        $json = json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
180        if ($json === false) {
181            return $this->json(['error' => 'Failed to encode userdata.'], Response::HTTP_INTERNAL_SERVER_ERROR);
182        }
183
184        // Create a temporary ZIP file
185        $tmpFile = tempnam(directory: sys_get_temp_dir(), prefix: 'pmf_userdata_');
186        if ($tmpFile === false) {
187            return $this->json(['error' => 'Failed to create temp file.'], Response::HTTP_INTERNAL_SERVER_ERROR);
188        }
189
190        $zipArchive = new ZipArchive();
191        if ($zipArchive->open($tmpFile, ZipArchive::OVERWRITE) !== true) {
192            return $this->json(['error' => 'Failed to create ZIP archive.'], Response::HTTP_INTERNAL_SERVER_ERROR);
193        }
194
195        $zipArchive->addFromString('userdata.json', $json);
196        $zipArchive->close();
197
198        $fileName = sprintf('phpmyfaq-userdata-%d-%s.zip', $this->currentUser->getUserId(), date(format: 'YmdHis'));
199
200        $binaryFileResponse = new BinaryFileResponse($tmpFile);
201        $binaryFileResponse->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $fileName);
202        $binaryFileResponse->headers->set('Content-Type', 'application/zip');
203        $binaryFileResponse->deleteFileAfterSend();
204
205        return $binaryFileResponse;
206    }
207
208    /**
209     * @throws Exception|\Exception
210     */
211    #[Route(path: 'user/request-removal', name: 'api.private.user.request-removal', methods: ['POST'])]
212    public function requestUserRemoval(Request $request): JsonResponse
213    {
214        $data = $this->getJsonObject($request);
215
216        if (($data->{'pmf-csrf-token'} ?? null) === null) {
217            throw new Exception('Missing CSRF token');
218        }
219
220        $csrfToken = Filter::filterVar($data->{'pmf-csrf-token'}, FILTER_SANITIZE_SPECIAL_CHARS);
221        if (!Token::getInstance($this->session)->verifyToken('request-removal', $csrfToken)) {
222            throw new Exception('Invalid CSRF token');
223        }
224
225        $userId = Filter::filterVar($data->userId ?? null, FILTER_VALIDATE_INT);
226        $author = trim((string) Filter::filterVar($data->name ?? null, FILTER_SANITIZE_SPECIAL_CHARS));
227        $loginName = trim((string) Filter::filterVar($data->loginname ?? null, FILTER_SANITIZE_SPECIAL_CHARS));
228        $email = trim((string) Filter::filterEmail($data->email ?? null));
229        $question = trim((string) Filter::filterVar($data->question ?? null, FILTER_SANITIZE_SPECIAL_CHARS));
230
231        // Validate User ID, Username and email
232        if ($userId === null) {
233            return $this->json([
234                'error' => Translation::get(key: 'ad_user_error_loginInvalid'),
235            ], Response::HTTP_BAD_REQUEST);
236        }
237
238        if (
239            !$this->currentUser->getUserById($userId)
240            || $userId !== $this->currentUser->getUserId()
241            || $loginName !== $this->currentUser->getLogin()
242            || $email !== $this->currentUser->getUserData('email')
243        ) {
244            return $this->json([
245                'error' => Translation::get(key: 'ad_user_error_loginInvalid'),
246            ], Response::HTTP_BAD_REQUEST);
247        }
248
249        if (
250            $author !== ''
251            && $author !== '0'
252            && $email !== ''
253            && $email !== '0'
254            && $question !== ''
255            && $question !== '0'
256            && $this->stopWords->checkBannedWord($question)
257        ) {
258            $question = sprintf(
259                '%s %s<br>%s %s<br>%s %s<br><br>%s',
260                Translation::getString(key: 'msgUsername'),
261                $loginName,
262                Translation::getString(key: 'msgNewContentName'),
263                $author,
264                Translation::getString(key: 'msgNewContentMail'),
265                $email,
266                $question,
267            );
268
269            try {
270                $this->mailer->setReplyTo($email, $author);
271                $this->mailer->addTo($this->configuration->getAdminEmail());
272                $this->mailer->subject = $this->configuration->getTitle() . ': Remove User Request';
273                $this->mailer->message = $question;
274                $this->mailer->send();
275
276                return $this->json(['success' => Translation::get(key: 'msgMailContact')], Response::HTTP_OK);
277            } catch (Exception|TransportExceptionInterface $exception) {
278                return $this->json(['error' => $exception->getMessage()], Response::HTTP_BAD_REQUEST);
279            }
280        }
281
282        return $this->json(['error' => Translation::get(key: 'err_sendMail')], Response::HTTP_BAD_REQUEST);
283    }
284
285    /**
286     * @throws \Exception|Exception|TwoFactorAuthException
287     */
288    #[Route(path: 'user/remove-twofactor', name: 'api.private.user.remove-twofactor', methods: ['POST'])]
289    public function removeTwofactorConfig(Request $request): JsonResponse
290    {
291        $data = json_decode($request->getContent());
292
293        if (!$data) {
294            throw new Exception('Invalid JSON data');
295        }
296
297        if (($data->csrfToken ?? null) === null) {
298            throw new Exception('Missing CSRF token');
299        }
300
301        $twoFactor = new TwoFactor($this->configuration, $this->currentUser);
302
303        $csrfToken = Filter::filterVar($data->csrfToken, FILTER_SANITIZE_SPECIAL_CHARS);
304        if (!Token::getInstance($this->session)->verifyToken('remove-twofactor', $csrfToken)) {
305            throw new Exception('Invalid CSRF token');
306        }
307
308        if (!$this->currentUser->isLoggedIn()) {
309            throw new Exception('The user is not logged in.');
310        }
311
312        $newSecret = $twoFactor->generateSecret();
313
314        if ($this->currentUser->setUserData(['secret' => $newSecret, 'twofactor_enabled' => 0])) {
315            return $this->json([
316                'success' => Translation::get('msgRemoveTwofactorConfigSuccessful'),
317            ], Response::HTTP_OK);
318        }
319
320        return $this->json(['error' => Translation::get(key: 'msgErrorOccurred')], Response::HTTP_BAD_REQUEST);
321    }
322}