Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.71% covered (warning)
79.71%
110 / 138
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserController
79.71% covered (warning)
79.71%
110 / 138
66.67% covered (warning)
66.67%
4 / 6
36.02
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
 requestRemoval
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 bookmarks
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 register
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 resetPassword
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
90
 ucp
86.15% covered (success)
86.15%
56 / 65
0.00% covered (danger)
0.00%
0 / 1
10.27
1<?php
2
3/**
4 * 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 * @author    Jan Harms <model_railroader@gmx-topmail.de>
13 * @copyright 2008-2026 phpMyFAQ Team
14 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
15 * @link      https://www.phpmyfaq.de
16 * @since     2008-01-25
17 */
18
19declare(strict_types=1);
20
21namespace phpMyFAQ\Controller\Frontend;
22
23use phpMyFAQ\Bookmark;
24use phpMyFAQ\Captcha\CaptchaInterface;
25use phpMyFAQ\Captcha\Helper\CaptchaHelperInterface;
26use phpMyFAQ\Core\Exception;
27use phpMyFAQ\Filter;
28use phpMyFAQ\Service\Gravatar;
29use phpMyFAQ\Session\Token;
30use phpMyFAQ\Translation;
31use phpMyFAQ\User\CurrentUser;
32use phpMyFAQ\User\PasswordResetTokenService;
33use phpMyFAQ\User\TwoFactor;
34use phpMyFAQ\User\UserSession;
35use RobThree\Auth\TwoFactorAuthException;
36use Symfony\Component\HttpFoundation\RedirectResponse;
37use Symfony\Component\HttpFoundation\Request;
38use Symfony\Component\HttpFoundation\Response;
39use Symfony\Component\Routing\Attribute\Route;
40
41final class UserController extends AbstractFrontController
42{
43    public function __construct(
44        private readonly UserSession $faqSession,
45        private readonly CaptchaInterface $captcha,
46        private readonly CaptchaHelperInterface $captchaHelper,
47        private readonly Gravatar $gravatar,
48    ) {
49        parent::__construct();
50    }
51
52    /**
53     * Displays the request removal page.
54     *
55     * @throws Exception
56     * @throws \Exception
57     */
58    #[Route(path: '/user/request-removal', name: 'public.user.request-removal', methods: ['GET'])]
59    public function requestRemoval(Request $request): Response
60    {
61        if (!$this->currentUser->isLoggedIn()) {
62            return new RedirectResponse($this->configuration->getDefaultUrl());
63        }
64
65        $this->faqSession->setCurrentUser($this->currentUser);
66        $this->faqSession->userTracking('request_removal', 0);
67
68        return $this->render('request-removal.twig', [
69            ...$this->getHeader($request),
70            'privacyURL' => $this->configuration->get('main.privacyURL'),
71            'csrf' => Token::getInstance($this->session)->getTokenInput('request-removal'),
72            'lang' => $this->configuration->getLanguage()->getLanguage(),
73            'userId' => $this->currentUser->getUserId(),
74            'defaultContentMail' => $this->currentUser->getUserId() > 0 ? $this->currentUser->getUserData('email') : '',
75            'defaultContentName' => $this->currentUser->getUserId() > 0
76                ? $this->currentUser->getUserData('display_name')
77                : '',
78            'defaultLoginName' => $this->currentUser->getUserId() > 0 ? $this->currentUser->getLogin() : '',
79        ]);
80    }
81
82    /**
83     * Displays the user's bookmarks page.
84     *
85     * @throws Exception
86     * @throws \Exception
87     */
88    #[Route(path: '/user/bookmarks', name: 'public.user.bookmarks', methods: ['GET'])]
89    public function bookmarks(Request $request): Response
90    {
91        if (!$this->currentUser->isLoggedIn()) {
92            return new RedirectResponse($this->configuration->getDefaultUrl());
93        }
94
95        $this->faqSession->setCurrentUser($this->currentUser);
96        $this->faqSession->userTracking('bookmarks', 0);
97
98        $bookmark = new Bookmark($this->configuration, $this->currentUser);
99
100        return $this->render('bookmarks.twig', [
101            ...$this->getHeader($request),
102            'title' => sprintf(
103                '%s - %s',
104                Translation::getString(key: 'msgBookmarks'),
105                $this->configuration->getTitle(),
106            ),
107            'bookmarksList' => $bookmark->getBookmarkList(),
108            'csrfTokenDeleteBookmark' => Token::getInstance($this->session)->getTokenString('delete-bookmark'),
109            'csrfTokenDeleteAllBookmarks' => Token::getInstance($this->session)->getTokenString('delete-all-bookmarks'),
110        ]);
111    }
112
113    /**
114     * Displays the user registration page.
115     *
116     * @throws Exception
117     * @throws \Exception
118     */
119    #[Route(path: '/user/register', name: 'public.user.register', methods: ['GET'])]
120    public function register(Request $request): Response
121    {
122        if (!$this->configuration->get('security.enableRegistration')) {
123            return new RedirectResponse($this->configuration->getDefaultUrl());
124        }
125
126        $this->faqSession->setCurrentUser($this->currentUser);
127        $this->faqSession->userTracking('registration', 0);
128
129        return $this->render('register.twig', [
130            ...$this->getHeader($request),
131            'title' => sprintf(
132                '%s - %s',
133                Translation::getString(key: 'msgRegistration'),
134                $this->configuration->getTitle(),
135            ),
136            'lang' => $this->configuration->getLanguage()->getLanguage(),
137            'isWebAuthnEnabled' => $this->configuration->get('security.enableWebAuthnSupport'),
138            'csrfTokenWebAuthn' => Token::getInstance($this->session)->getTokenString('webauthn'),
139            'captchaFieldset' => $this->captchaHelper->renderCaptcha(
140                $this->captcha,
141                'register',
142                Translation::getString(key: 'msgCaptcha'),
143                $this->currentUser->isLoggedIn(),
144            ),
145        ]);
146    }
147
148    /**
149     * Displays the password reset form for a signed reset link. The signature
150     * is verified server-side before rendering the form so that an invalid or
151     * expired link cannot be used to attempt a reset.
152     *
153     * @throws Exception
154     * @throws \Exception
155     */
156    #[Route(path: '/user/reset-password', name: 'public.user.reset-password', methods: ['GET'])]
157    public function resetPassword(Request $request): Response
158    {
159        $this->faqSession->userTracking('reset_password', 0);
160
161        $userId = (int) Filter::filterVar($request->query->get('u'), FILTER_VALIDATE_INT);
162        $expires = (int) Filter::filterVar($request->query->get('exp'), FILTER_VALIDATE_INT);
163        $signature = (string) Filter::filterVar($request->query->get('sig'), FILTER_SANITIZE_SPECIAL_CHARS);
164
165        $valid = false;
166        if ($userId > 0 && $expires > 0 && $signature !== '') {
167            $candidate = CurrentUser::getCurrentUser($this->configuration);
168            if ($candidate->getUserById($userId, true)) {
169                $passwordKey = $candidate->getEncryptedPassword();
170                if ($passwordKey !== '') {
171                    $valid = new PasswordResetTokenService()->verify($userId, $expires, $signature, $passwordKey);
172                }
173            }
174        }
175
176        return $this->render('resetpw.twig', [
177            ...$this->getHeader($request),
178            'lang' => $this->configuration->getLanguage()->getLanguage(),
179            'resetUserId' => $valid ? $userId : 0,
180            'resetExpires' => $valid ? $expires : 0,
181            'resetSignature' => $valid ? $signature : '',
182            'resetTokenValid' => $valid,
183        ]);
184    }
185
186    /**
187     * Displays the User Control Panel.
188     *
189     * @throws Exception
190     * @throws \Exception
191     */
192    #[Route(path: '/user/ucp', name: 'public.user.ucp', methods: ['GET'])]
193    public function ucp(Request $request): Response
194    {
195        if (!$this->currentUser->isLoggedIn()) {
196            return new RedirectResponse($this->configuration->getDefaultUrl());
197        }
198
199        $this->faqSession->setCurrentUser($this->currentUser);
200        $this->faqSession->userTracking('user_control_panel', $this->currentUser->getUserId());
201
202        $gravatarImg = '';
203        if ($this->configuration->get('main.enableGravatarSupport')) {
204            $email = $this->currentUser->getUserData('email');
205            $gravatarImg = sprintf('<a target="_blank" href="https://www.gravatar.com">%s</a>', $this->gravatar->getImage(
206                is_string($email) ? $email : '',
207                ['class' => 'img-responsive rounded-circle', 'size' => '125'],
208            ));
209        }
210
211        $qrCode = '';
212        $secret = '';
213        try {
214            $twoFactor = new TwoFactor($this->configuration, $this->currentUser);
215            $secret = $twoFactor->getSecret($this->currentUser);
216            if ('' === $secret || is_null($secret)) {
217                try {
218                    $secret = $twoFactor->generateSecret();
219                } catch (TwoFactorAuthException $exception) {
220                    $this->configuration->getLogger()->error('Cannot generate 2FA secret: ' . $exception->getMessage());
221                }
222
223                $twoFactor->saveSecret($secret ?? '');
224            }
225
226            $qrCode = $twoFactor->getQrCode($secret ?? '');
227        } catch (TwoFactorAuthException|\Exception $exception) {
228            $this->configuration->getLogger()->error('2FA error: ' . $exception->getMessage());
229        }
230
231        return $this->render('ucp.twig', [
232            ...$this->getHeader($request),
233            'headerUserControlPanel' => Translation::get(key: 'headerUserControlPanel'),
234            'ucpGravatarImage' => $gravatarImg,
235            'msgHeaderUserData' => Translation::get(key: 'headerUserControlPanel'),
236            'userid' => $this->currentUser->getUserId(),
237            'csrf' => Token::getInstance($this->session)->getTokenInput('ucp'),
238            'lang' => $this->configuration->getLanguage()->getLanguage(),
239            'readonly' => $this->currentUser->isLocalUser() ? '' : 'readonly disabled',
240            'msgRealName' => Translation::get(key: 'ad_user_name'),
241            'realname' => $this->currentUser->getUserData('display_name'),
242            'msgEmail' => Translation::get(key: 'msgNewContentMail'),
243            'email' => $this->currentUser->getUserData('email'),
244            'msgIsVisible' => Translation::get(key: 'msgUserDataVisible'),
245            'checked' => (int) $this->currentUser->getUserData('is_visible') === 1 ? 'checked' : '',
246            'msgPassword' => Translation::get(key: 'ad_auth_passwd'),
247            'msgConfirm' => Translation::get(key: 'ad_user_confirm'),
248            'msgSave' => Translation::get(key: 'msgSave'),
249            'msgCancel' => Translation::get(key: 'msgCancel'),
250            'twofactor_enabled' => (bool) $this->currentUser->getUserData('twofactor_enabled'),
251            'msgTwofactorEnabled' => Translation::get(key: 'msgTwofactorEnabled'),
252            'msgTwofactorConfig' => Translation::get(key: 'msgTwofactorConfig'),
253            'msgTwofactorConfigModelTitle' => Translation::get(key: 'msgTwofactorConfigModelTitle'),
254            'twofactor_secret' => $secret,
255            'qr_code_secret' => $qrCode,
256            'qr_code_secret_alt' => Translation::get(key: 'qr_code_secret_alt'),
257            'msgTwofactorNewSecret' => Translation::get(key: 'msgTwofactorNewSecret'),
258            'msgWarning' => Translation::get(key: 'msgWarning'),
259            'ad_gen_yes' => Translation::get(key: 'ad_gen_yes'),
260            'ad_gen_no' => Translation::get(key: 'ad_gen_no'),
261            'msgConfirmTwofactorConfig' => Translation::get(key: 'msgConfirmTwofactorConfig'),
262            'csrfTokenRemoveTwofactor' => Token::getInstance($this->session)->getTokenString('remove-twofactor'),
263            'msgGravatarNotConnected' => Translation::get(key: 'msgGravatarNotConnected'),
264            'webauthnSupportEnabled' => $this->configuration->get('security.enableWebAuthnSupport'),
265            'csrfTokenWebAuthn' => Token::getInstance($this->session)->getTokenString('webauthn'),
266            'loginName' => $this->currentUser->getLogin(),
267            'csrfExportUserData' => Token::getInstance($this->session)->getTokenInput('export-userdata'),
268            'exportUserDataUrl' => 'api/user/data/export',
269            'msgDownloadYourData' => Translation::get(key: 'msgDownloadYourData'),
270            'msgDataExportDescription' => Translation::get(key: 'msgDataExportDescription'),
271            'msgDownload' => Translation::get(key: 'msgDownload'),
272        ]);
273    }
274}