Lines 95.45% 420 / 440
Methods 74.28% 26 / 35
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 login 100.00% 32 / 32 100.00% 1 / 1 4
 forgotPassword 100.00% 8 / 8 100.00% 1 / 1 1
 logout 100.00% 20 / 20 100.00% 1 / 1 9
 authenticate 94.28% 33 / 35 0.00% 0 / 1 10.02
 token 100.00% 17 / 17 100.00% 1 / 1 2
 check 100.00% 28 / 28 100.00% 1 / 1 9
 [phpMyFAQ\Controller\Frontend\AbstractFrontController] initializeFromContainer 90.90% 10 / 11 0.00% 0 / 1 4.01
 [phpMyFAQ\Controller\Frontend\AbstractFrontController] getHeader 100.00% 78 / 78 100.00% 1 / 1 9
 [phpMyFAQ\Controller\Frontend\AbstractFrontController] getTopNavigation 100.00% 23 / 23 100.00% 1 / 1 5
 [phpMyFAQ\Controller\Frontend\AbstractFrontController] getUserDropdown 100.00% 21 / 21 100.00% 1 / 1 5
 [phpMyFAQ\Controller\Frontend\AbstractFrontController] getFooterNavigation 100.00% 23 / 23 100.00% 1 / 1 5
 [phpMyFAQ\Controller\Frontend\AbstractFrontController] handleStaticPageRedirect 53.84% 7 / 13 0.00% 0 / 1 7.46
 [phpMyFAQ\Controller\AbstractController] setContainer 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] render 100.00% 5 / 5 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] renderView 0.00% 0 / 3 0.00% 0 / 1 2
 [phpMyFAQ\Controller\AbstractController] json 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] getJsonObject 75.00% 3 / 4 0.00% 0 / 1 2.06
 [phpMyFAQ\Controller\AbstractController] getTwigWrapper 100.00% 10 / 10 100.00% 1 / 1 3
 [phpMyFAQ\Controller\AbstractController] hasValidToken 85.71% 6 / 7 0.00% 0 / 1 5.07
 [phpMyFAQ\Controller\AbstractController] isSecured 100.00% 10 / 10 100.00% 1 / 1 5
 [phpMyFAQ\Controller\AbstractController] isPublicAuthenticationPath 100.00% 23 / 23 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] userIsAuthenticated 100.00% 2 / 2 100.00% 1 / 1 2
 [phpMyFAQ\Controller\AbstractController] userIsSuperAdmin 100.00% 2 / 2 100.00% 1 / 1 2
 [phpMyFAQ\Controller\AbstractController] userHasGroupPermission 100.00% 8 / 8 100.00% 1 / 1 6
 [phpMyFAQ\Controller\AbstractController] userHasUserPermission 100.00% 7 / 7 100.00% 1 / 1 5
 [phpMyFAQ\Controller\AbstractController] userHasPermission 100.00% 5 / 5 100.00% 1 / 1 3
 [phpMyFAQ\Controller\AbstractController] userHasAnyPermission 100.00% 10 / 10 100.00% 1 / 1 4
 [phpMyFAQ\Controller\AbstractController] verifySessionCsrfToken 70.00% 7 / 10 0.00% 0 / 1 4.43
 [phpMyFAQ\Controller\AbstractController] captchaCodeIsValid 85.71% 6 / 7 0.00% 0 / 1 2.01
 [phpMyFAQ\Controller\AbstractController] isApiEnabled 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] addExtension 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] addFilter 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] getRateLimiter 100.00% 4 / 4 100.00% 1 / 1 3
 [phpMyFAQ\Controller\AbstractController] createFallbackContainer 71.42% 5 / 7 0.00% 0 / 1 2.09
37final class AuthenticationController extends AbstractFrontController
38{
39    public function __construct(
40        private readonly UserSession $userSession,
41        private readonly CurrentUser $currentUserService,
42        private readonly TwoFactor $twoFactor,
43    ) {
44        parent::__construct();
45    }
46
47    /**
48     * @throws Exception
49     * @throws LoaderError
50     * @throws \Exception
51     */
52    #[Route(path: '/login', name: 'public.auth.login', methods: ['GET'])]
53    public function login(Request $request): Response
54    {
55        $this->userSession->setCurrentUser($this->currentUser);
56        $this->userSession->userTracking('login', 0);
57
58        // Redirect to authenticate if SSO is enabled and the user is already authenticated
59        if (
60            (bool) $this->configuration->get(item: 'security.ssoSupport')
61            && $request->server->get(key: 'REMOTE_USER') !== null
62        ) {
63            return new RedirectResponse(url: './authenticate');
64        }
65
66        $errorMessages = $this->session->getFlashBag()->get('error');
67        $errorMessage = count($errorMessages) > 0 ? $errorMessages[0] : null;
68
69        return $this->render('login.twig', [
70            ...$this->getHeader($request),
71            'title' => sprintf(
72                '%s - %s',
73                Translation::getString(key: 'msgLoginUser'),
74                $this->configuration->getTitle(),
75            ),
76            'loginHeader' => Translation::get(key: 'msgLoginUser'),
77            'errorMessage' => $errorMessage,
78            'writeLoginPath' => $this->configuration->getDefaultUrl(),
79            'login' => Translation::get(key: 'ad_auth_ok'),
80            'username' => Translation::get(key: 'ad_auth_user'),
81            'password' => Translation::get(key: 'ad_auth_passwd'),
82            'rememberMe' => Translation::get(key: 'rememberMe'),
83            'msgTwofactorEnabled' => Translation::get(key: 'msgTwofactorEnabled'),
84            'msgTwofactorTokenModelTitle' => Translation::get(key: 'msgTwofactorTokenModelTitle'),
85            'msgEnterTwofactorToken' => Translation::get(key: 'msgEnterTwofactorToken'),
86            'msgTwofactorCheck' => Translation::get(key: 'msgTwofactorCheck'),
87            'userid' => $this->currentUser->getUserId(),
88            'enableRegistration' => $this->configuration->get('security.enableRegistration'),
89            'registerUser' => Translation::get(key: 'msgRegistration'),
90            'useSignInWithMicrosoft' => $this->configuration->isSignInWithMicrosoftActive(),
91            'useSignInWithKeycloak' => $this->configuration->isSignInWithKeycloakActive(),
92            'isWebAuthnEnabled' => $this->configuration->get('security.enableWebAuthnSupport'),
93        ]);
94    }
95
96    /**
97     * @throws Exception
98     * @throws LoaderError
99     * @throws \Exception
100     */
101    #[Route(path: '/forgot-password', name: 'public.forgot-password', methods: ['GET', 'POST'])]
102    public function forgotPassword(Request $request): Response
103    {
104        $this->userSession->setCurrentUser($this->currentUser);
105        $this->userSession->userTracking('forgot_password', 0);
106
107        return $this->render('password.twig', [
108            ...$this->getHeader($request),
109            'lang' => $this->configuration->getLanguage()->getLanguage(),
110            'username' => Translation::get(key: 'ad_auth_user'),
111            'password' => Translation::get(key: 'ad_auth_passwd'),
112        ]);
113    }
114
115    /**
116     * @throws \Exception
117     */
118    #[Route(path: '/logout', name: 'public.auth.logout', methods: ['GET'])]
119    public function logout(Request $request): RedirectResponse
120    {
121        $csrfToken = Filter::filterVar($request->query->get('csrf'), FILTER_SANITIZE_SPECIAL_CHARS, '');
122
123        $redirectResponse = new RedirectResponse(url: $this->configuration->getDefaultUrl());
124
125        if (!Token::getInstance($this->session)->verifyToken('logout', $csrfToken)) {
126            $this->session->getFlashBag()->add('error', 'CSRF Problem detected: ' . $csrfToken);
127            return $redirectResponse;
128        }
129
130        if (!$this->currentUser->isLoggedIn()) {
131            return $redirectResponse;
132        }
133
134        $this->currentUser->deleteFromSession(true);
135
136        // Add a success message
137        $this->session->getFlashBag()->add('success', Translation::get('ad_logout'));
138
139        // SSO Logout
140        $ssoLogout = (string) ($this->configuration->get('security.ssoLogoutRedirect') ?? '');
141        if ((bool) $this->configuration->get('security.ssoSupport') && $ssoLogout !== '') {
142            $redirectResponse->isRedirect($ssoLogout);
143            return $redirectResponse;
144        }
145
146        // Microsoft Azure Logout
147        if (
148            $this->configuration->isSignInWithMicrosoftActive()
149            && $this->currentUser->getUserAuthSource() === 'azure'
150        ) {
151            return new RedirectResponse($this->configuration->getDefaultUrl() . 'auth/azure/logout');
152        }
153
154        if (
155            $this->configuration->isSignInWithKeycloakActive()
156            && $this->currentUser->getUserAuthSource() === 'keycloak'
157        ) {
158            return new RedirectResponse($this->configuration->getDefaultUrl() . 'auth/keycloak/logout');
159        }
160
161        return $redirectResponse;
162    }
163
164    /**
165     * Handles user authentication (login form submission)
166     *
167     * @throws \Exception
168     */
169    #[Route(path: '/authenticate', name: 'public.auth.authenticate', methods: ['POST'])]
170    public function authenticate(Request $request): RedirectResponse
171    {
172        if ($this->currentUser->isLoggedIn()) {
173            return new RedirectResponse(url: './');
174        }
175
176        $username = Filter::filterVar($request->request->get('faqusername'), FILTER_SANITIZE_SPECIAL_CHARS, '');
177        $password = Filter::filterVar(
178            $request->request->get('faqpassword'),
179            FILTER_SANITIZE_SPECIAL_CHARS,
180            FILTER_FLAG_NO_ENCODE_QUOTES,
181        );
182        $rememberMe = Filter::filterVar($request->request->get('faqrememberme'), FILTER_VALIDATE_BOOLEAN);
183
184        // Set username via SSO
185        if (
186            (bool) $this->configuration->get(item: 'security.ssoSupport')
187            && $request->server->get(key: 'REMOTE_USER') !== null
188        ) {
189            $username = trim((string) $request->server->get(key: 'REMOTE_USER'));
190            $password = '';
191        }
192
193        // Login via local DB or LDAP or SSO
194        if ($username !== '' && ($password !== '' || (bool) $this->configuration->get('security.ssoSupport'))) {
195            $userAuthentication = new UserAuthentication(
196                $this->configuration,
197                $this->currentUser,
198                $this->getRateLimiter(),
199            );
200            $userAuthentication->setRememberMe($rememberMe ?? false);
201            try {
202                $this->currentUser = $userAuthentication->authenticate($username, (string) $password);
203
204                // Check if two-factor authentication is enabled
205                if ($userAuthentication->hasTwoFactorAuthentication()) {
206                    // The failure count is deliberately not reset here: a correct
207                    // password must not buy a fresh budget of token guesses.
208                    if ($this->currentUser->isTwoFactorLockedOut()) {
209                        $this->session->getFlashBag()->add('error', Translation::get('ad_auth_fail'));
210                        return new RedirectResponse('./login');
211                    }
212
213                    // Bind the pending 2FA step to this user, but only after the password was validated
214                    $this->session->set('2fa_pending_user_id', $this->currentUser->getUserId());
215                    // The remember-me cookie must not be issued until the second factor has
216                    // been verified. Carry the request through the token step so check() can
217                    // issue the cookie only after a successful 2FA challenge.
218                    $this->session->set('2fa_pending_remember_me', $userAuthentication->isRememberMe());
219                    return new RedirectResponse(url: './token?user-id=' . $this->currentUser->getUserId());
220                }
221
222                return new RedirectResponse('./');
223            } catch (UserException $e) {
224                // Log the specific reason server-side, but never disclose whether the login
225                // name exists: always show the same generic message to prevent user enumeration.
226                $this->configuration->getLogger()->error('Login-error: ' . $e->getMessage());
227                $this->session->getFlashBag()->add('error', Translation::get('ad_auth_fail'));
228                return new RedirectResponse('./login');
229            }
230        }
231
232        $this->session->getFlashBag()->add('error', Translation::get('ad_auth_fail'));
233        return new RedirectResponse($this->configuration->getDefaultUrl() . 'login');
234    }
235
236    /**
237     * Displays the two-factor authentication page
238     *
239     * @throws Exception
240     * @throws LoaderError
241     * @throws \Exception
242     */
243    #[Route(path: '/token', name: 'public.auth.token', methods: ['GET'])]
244    public function token(Request $request): Response
245    {
246        if ($this->currentUser->isLoggedIn()) {
247            return new RedirectResponse(url: './');
248        }
249
250        $this->userSession->setCurrentUser($this->currentUser);
251        $this->userSession->userTracking('twofactor', 0);
252
253        $userId = (int) Filter::filterVar($request->query->get(key: 'user-id'), FILTER_VALIDATE_INT);
254
255        return $this->render('twofactor.twig', [
256            ...$this->getHeader($request),
257            'title' => sprintf(
258                '%s - %s',
259                Translation::getString(key: 'msgTwofactorEnabled'),
260                $this->configuration->getTitle(),
261            ),
262            'msgTwofactorEnabled' => Translation::get(key: 'msgTwofactorEnabled'),
263            'msgEnterTwofactorToken' => Translation::get(key: 'msgEnterTwofactorToken'),
264            'msgTwofactorCheck' => Translation::get(key: 'msgTwofactorCheck'),
265            'userId' => $userId,
266        ]);
267    }
268
269    /**
270     * Validates the two-factor authentication token
271     *
272     * @throws \Exception
273     */
274    #[Route(path: '/check', name: 'public.auth.check', methods: ['POST'])]
275    public function check(Request $request): RedirectResponse
276    {
277        if ($this->currentUser->isLoggedIn()) {
278            return new RedirectResponse(url: './');
279        }
280
281        $token = Filter::filterVar($request->request->get(key: 'token'), FILTER_SANITIZE_SPECIAL_CHARS, '');
282        $userId = (int) Filter::filterVar($request->request->get(key: 'user-id'), FILTER_VALIDATE_INT);
283
284        if ($userId <= 0) {
285            $this->session->getFlashBag()->add('error', Translation::get('msgTwofactorErrorToken'));
286            return new RedirectResponse('./token?user-id=' . $userId);
287        }
288
289        // The 2FA step is only reachable once the password was validated for exactly this user
290        $pendingUserId = $this->session->get('2fa_pending_user_id');
291        if ($pendingUserId === null || (int) $pendingUserId !== $userId) {
292            return new RedirectResponse('./login');
293        }
294
295        $this->currentUserService->getUserById($userId);
296
297        // The failure count lives on the account, not in the session, so that neither
298        // a fresh session nor another password authentication can clear it.
299        if ($this->currentUserService->isTwoFactorLockedOut()) {
300            $this->session->remove('2fa_pending_user_id');
301            $this->session->remove('2fa_pending_remember_me');
302            return new RedirectResponse('./login');
303        }
304
305        if (strlen((string) $token) === 6) {
306            $result = $this->twoFactor->validateToken($token, $userId);
307
308            if ($result) {
309                $this->session->remove('2fa_pending_user_id');
310                $rememberMe = true === $this->session->get('2fa_pending_remember_me');
311                $this->session->remove('2fa_pending_remember_me');
312                // twoFactorSuccess() clears the counter via setSuccess().
313                $this->currentUserService->twoFactorSuccess();
314                // The second factor is now verified, so the remember-me cookie can safely
315                // be issued for the fully authenticated session.
316                if ($rememberMe) {
317                    $this->currentUserService->issueRememberMeCookie();
318                }
319
320                return new RedirectResponse(url: './');
321            }
322        }
323
324        $this->currentUserService->twoFactorFailure();
325
326        $this->session->getFlashBag()->add('error', Translation::get('msgTwofactorErrorToken'));
327        return new RedirectResponse('./token?user-id=' . $userId);
328    }
329}

Inherited from phpMyFAQ\Controller\Frontend\AbstractFrontController

46    protected function initializeFromContainer(): void
47    {
48        parent::initializeFromContainer();
49
50        /* @mago-expect lint:no-isset - typed property may be uninitialized */
51        if (!isset($this->container)) {
52            throw new LogicException('Container is not initialized.');
53        }
54
55        $faqSystem = $this->container->get(id: 'phpmyfaq.system');
56        if (!$faqSystem instanceof System) {
57            throw new LogicException('System service not found in container.');
58        }
59
60        $this->faqSystem = $faqSystem;
61
62        $seo = $this->container->get(id: 'phpmyfaq.seo');
63        if (!$seo instanceof Seo) {
64            throw new LogicException('Seo service not found in container.');
65        }
66
67        $this->seo = $seo;
68    }
74    protected function getHeader(Request $request): array
75    {
76        $action = $request->query->get(key: 'action', default: 'index');
77
78        $isUserHasAdminRights = $this->currentUser->perm->hasPermission(
79            $this->currentUser->getUserId(),
80            PermissionType::VIEW_ADMIN_LINK->value,
81        );
82
83        // Get flash messages
84        $successMessages = $this->session->getFlashBag()->get('success');
85        $errorMessages = $this->session->getFlashBag()->get('error');
86
87        return [
88            ...$this->getUserDropdown(),
89            'successMessage' => count($successMessages) > 0 ? $successMessages[0] : null,
90            'errorMessage' => count($errorMessages) > 0 ? $errorMessages[0] : null,
91            'isMaintenanceMode' => $this->configuration->get('main.maintenanceMode'),
92            'isCompletelySecured' => $this->configuration->get('security.enableLoginOnly'),
93            'isDebugEnabled' => Environment::isDebugMode(),
94            'richSnippetsEnabled' => $this->configuration->get('seo.enableRichSnippets'),
95            'tplSetName' => TwigWrapper::getTemplateSetName(),
96            'msgLoginUser' => $this->currentUser->isLoggedIn()
97                ? $this->currentUser->getUserData('display_name')
98                : Translation::get(key: 'msgLoginUser'),
99            'isUserLoggedIn' => $this->currentUser->isLoggedIn(),
100            'isUserHasAdminRights' => $isUserHasAdminRights || $this->currentUser->isSuperAdmin(),
101            'baseHref' => $this->faqSystem->getSystemUri($this->configuration),
102            'customCss' => $this->configuration->getCustomCss(),
103            'defaultLayoutMode' => (string) ($this->configuration->get('layout.defaultLayoutMode') ?? 'auto'),
104            'allowUserLayoutMode' =>
105                $this->configuration->get('layout.allowUserLayoutMode') === true
106                    || $this->configuration->get('layout.allowUserLayoutMode') === 'true',
107            'version' => $this->configuration->getVersion(),
108            'header' => str_replace(search: '"', replace: '', subject: $this->configuration->getTitle()),
109            'metaDescription' => $this->configuration->get('seo.description'),
110            'metaPublisher' => $this->configuration->get('main.metaPublisher'),
111            'metaLanguage' => Translation::get(key: 'metaLanguage'),
112            'metaRobots' => $this->seo->getMetaRobots($action),
113            'phpmyfaqVersion' => $this->configuration->getVersion(),
114            'stylesheet' => Translation::get(key: 'direction') === 'rtl' ? 'style.rtl' : 'style',
115            'currentPageUrl' => $request->getSchemeAndHttpHost() . $request->getRequestUri(),
116            'action' => $action,
117            'dir' => Translation::get(key: 'direction'),
118            'formActionUrl' => './search',
119            'searchBox' => Translation::get(key: 'msgSearch'),
120            'languageBox' => Translation::get(key: 'msgLanguageSubmit'),
121            'switchLanguages' => LanguageHelper::renderSelectLanguage(
122                $this->configuration->getLanguage()->getLanguage(),
123                true,
124            ),
125            'copyright' => System::getPoweredByString(),
126            'isUserRegistrationEnabled' => $this->configuration->get('security.enableRegistration'),
127            'pluginStylesheets' => $this->configuration->getPluginManager()->getAllPluginStylesheets(),
128            'pluginScripts' => $this->configuration->getPluginManager()->getAllPluginScripts(),
129            'msgFullName' => Translation::getString(key: 'ad_user_loggedin') . $this->currentUser->getLogin(),
130            'msgLoginName' => $this->currentUser->getUserData('display_name'),
131            'loginHeader' => Translation::get(key: 'msgLoginUser'),
132            'msgAdvancedSearch' => Translation::get(key: 'msgAdvancedSearch'),
133            'currentYear' => date(format: 'Y', timestamp: time()),
134            'cookieConsentEnabled' => $this->configuration->get('layout.enableCookieConsent'),
135            'faqHome' => $this->configuration->getDefaultUrl(),
136            'topNavigation' => $this->getTopNavigation($request),
137            'isAskQuestionsEnabled' => $this->configuration->get('main.enableAskQuestions'),
138            'isOpenQuestionsEnabled' => $this->configuration->get('main.enableAskQuestions'),
139            'footerNavigation' => $this->getFooterNavigation($request),
140            'isPrivacyLinkEnabled' => $this->configuration->get('layout.enablePrivacyLink'),
141            'msgPrivacyNote' => Translation::get(key: 'msgPrivacyNote'),
142            'isTermsLinkEnabled' => (string) $this->configuration->get('main.termsURL') !== '',
143            'msgTermsOfService' => Translation::get(key: 'msgTermsOfService'),
144            'isImprintLinkEnabled' => (string) $this->configuration->get('main.imprintURL') !== '',
145            'msgImprint' => Translation::get(key: 'msgImprint'),
146            'isCookieConsentEnabled' => $this->configuration->get('layout.enableCookieConsent'),
147            'cookiePreferences' => Translation::get(key: 'cookiePreferences'),
148            'isAccessibilityStatementEnabled' =>
149                (string) $this->configuration->get('main.accessibilityStatementURL') !== '',
150            'msgAccessibilityStatement' => Translation::get(key: 'msgAccessibilityStatement'),
151            'pushEnabled' =>
152                (
153                    $this->configuration->get('push.enableWebPush') === 'true'
154                    || $this->configuration->get('push.enableWebPush') === true
155                )
156                    && (string) $this->configuration->get('push.vapidPublicKey') !== '',
157        ];
158    }
160    private function getTopNavigation(Request $request): array
161    {
162        $action = $request->query->get(key: 'action', default: 'index');
163
164        return [
165            [
166                'name' => Translation::get(key: 'msgShowAllCategories'),
167                'link' => './show-categories.html',
168                'active' => 'show' === $action ? 'active' : '',
169            ],
170            [
171                'name' => Translation::get(key: 'msgAddContent'),
172                'link' => './add-faq.html',
173                'active' => 'add' === $action ? 'active' : '',
174            ],
175            [
176                'name' => Translation::get(key: 'msgQuestion'),
177                'link' => './add-question.html',
178                'active' => 'ask' === $action ? 'active' : '',
179            ],
180            [
181                'name' => Translation::get(key: 'msgOpenQuestions'),
182                'link' => './open-questions.html',
183                'active' => 'open-questions' === $action ? 'active' : '',
184            ],
185        ];
186    }
191    private function getUserDropdown(): array
192    {
193        $templateVars = [];
194        if ($this->currentUser->isLoggedIn() && $this->currentUser->getUserId() > 0) {
195            $csrfLogoutToken = Token::getInstance($this->session)->getTokenString('logout');
196
197            if (
198                $this->currentUser->perm->hasPermission(
199                    $this->currentUser->getUserId(),
200                    PermissionType::VIEW_ADMIN_LINK->value,
201                )
202                || $this->currentUser->isSuperAdmin()
203            ) {
204                $templateVars = [
205                    ...$templateVars,
206                    'msgAdmin' => Translation::get(key: 'adminSection'),
207                ];
208            }
209
210            $templateVars = [
211                ...$templateVars,
212                'msgUserControlDropDown' => Translation::get(key: 'headerUserControlPanel'),
213                'msgBookmarks' => Translation::get(key: 'msgBookmarks'),
214                'msgUserRemoval' => Translation::get(key: 'ad_menu_RequestRemove'),
215                'msgLogoutUser' => Translation::get(key: 'ad_menu_logout'),
216                'csrfLogout' => $csrfLogoutToken,
217            ];
218        }
219
220        return $templateVars;
221    }
223    private function getFooterNavigation(Request $request): array
224    {
225        $action = $request->query->get(key: 'action', default: 'index');
226
227        return [
228            [
229                'name' => Translation::get(key: 'faqOverview'),
230                'link' => './overview.html',
231                'active' => 'faq-overview' === $action ? 'active' : '',
232            ],
233            [
234                'name' => Translation::get(key: 'msgSitemap'),
235                'link' => './sitemap/A/' . $this->configuration->getLanguage()->getLanguage() . '.html',
236                'active' => 'sitemap' === $action ? 'active' : '',
237            ],
238            [
239                'name' => Translation::get(key: 'ad_menu_glossary'),
240                'link' => './glossary.html',
241                'active' => 'glossary' === $action ? 'active' : '',
242            ],
243            [
244                'name' => Translation::get(key: 'msgContact'),
245                'link' => './contact.html',
246                'active' => 'contact' === $action ? 'active' : '',
247            ],
248        ];
249    }
259    protected function handleStaticPageRedirect(string $configKey): Response
260    {
261        $url = $this->configuration->get($configKey);
262
263        // Check if this is a reference to a custom page (format: "page:slug")
264        if (str_starts_with((string) $url, 'page:')) {
265            $slug = substr(string: (string) $url, offset: 5);
266            $customPage = new CustomPage($this->configuration);
267            $page = $customPage->getBySlug($slug);
268
269            if ($page && $page->isActive()) {
270                // Redirect to the custom page URL
271                $pageUrl = $this->configuration->getDefaultUrl() . 'page/' . $page->getSlug() . '.html';
272                return new RedirectResponse($pageUrl);
273            }
274        }
275
276        // Default behavior: redirect to external URL
277        if ((string) $url !== '') {
278            return new RedirectResponse((string) $url);
279        }
280
281        // If no URL configured and no fallback, return 404
282        $response = new Response();
283        $response->setStatusCode(Response::HTTP_NOT_FOUND);
284        return $this->render('404.twig', [], $response);
285    }

Inherited from phpMyFAQ\Controller\AbstractController

93    public function setContainer(ContainerInterface $container): void
94    {
95        $this->container = $container;
96        $this->initializeFromContainer();
97    }
137    public function render(string $file, array $context = [], ?Response $response = null): Response
138    {
139        $response ??= new Response();
140        $twigWrapper = $this->getTwigWrapper();
141        $templateWrapper = $twigWrapper->loadTemplate($file);
142
143        $response->setContent($templateWrapper->render($context));
144
145        return $response;
146    }
154    public function renderView(string $pathToTwigFile, array $templateVars = []): string
155    {
156        $twigWrapper = $this->getTwigWrapper();
157        $templateWrapper = $twigWrapper->loadTemplate($pathToTwigFile);
158
159        return $templateWrapper->render($templateVars);
160    }
167    public function json(mixed $data, int $status = 200, array $headers = []): JsonResponse
168    {
169        return new JsonResponse($data, $status, $headers);
170    }
182    protected function getJsonObject(Request $request): \stdClass
183    {
184        /* @mago-expect analysis:mixed-assignment - json_decode() is mixed by nature; validated to stdClass below */
185        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
186
187        if (!$data instanceof \stdClass) {
188            throw new JsonException('The request body must be a JSON object.');
189        }
190
191        return $data;
192    }
197    public function getTwigWrapper(): TwigWrapper
198    {
199        $twigWrapper = new TwigWrapper(
200            (string) PMF_ROOT_DIR . '/assets/templates',
201            false,
202            $this->configuration->getTemplateSet(),
203        );
204
205        foreach ($this->twigExtensions as $twigExtension) {
206            $twigWrapper->addExtension($twigExtension);
207        }
208
209        foreach ($this->twigFilters as $twigFilter) {
210            $twigWrapper->addFilter($twigFilter);
211        }
212
213        return $twigWrapper;
214    }
219    protected function hasValidToken(): void
220    {
221        $configuredToken = $this->configuration->get(item: 'api.apiClientToken');
222        if (!is_string($configuredToken) || $configuredToken === '') {
223            throw new UnauthorizedHttpException(challenge: '"x-pmf-token" is not valid.');
224        }
225
226        $request = Request::createFromGlobals();
227        $requestToken = $request->headers->get(key: 'x-pmf-token');
228        if (!is_string($requestToken) || !hash_equals($configuredToken, $requestToken)) {
229            throw new UnauthorizedHttpException(challenge: '"x-pmf-token" is not valid.');
230        }
231    }
236    protected function isSecured(): void
237    {
238        if ($this->currentUser->isLoggedIn()) {
239            return;
240        }
241
242        if (!$this->configuration->get(item: 'security.enableLoginOnly')) {
243            return;
244        }
245
246        $request = Request::createFromGlobals();
247        $pathInfo = rtrim($request->getPathInfo(), characters: '/');
248        $pathInfo = $pathInfo === '' ? '/' : $pathInfo;
249
250        if ($this->isPublicAuthenticationPath($pathInfo)) {
251            return;
252        }
253
254        throw new UnauthorizedHttpException(challenge: 'You are not allowed to view this content.');
255    }
257    private function isPublicAuthenticationPath(string $pathInfo): bool
258    {
259        $publicAuthenticationPaths = [
260            '/login',
261            '/authenticate',
262            '/forgot-password',
263            '/token',
264            '/check',
265            '/contact.html',
266            '/imprint.html',
267            '/privacy.html',
268            '/terms.html',
269            '/accessibility.html',
270            '/auth/azure/authorize',
271            '/auth/azure/callback',
272            '/auth/azure/callback.php',
273            '/auth/keycloak/authorize',
274            '/auth/keycloak/callback',
275            '/auth/keycloak/logout',
276            '/services/azure/callback',
277            '/services/azure/callback.php',
278            '/api/webauthn/prepare-login',
279            '/api/webauthn/login',
280        ];
281
282        return in_array($pathInfo, $publicAuthenticationPaths, strict: true);
283    }
288    public function userIsAuthenticated(): void
289    {
290        if (!$this->currentUser->isLoggedIn()) {
291            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
292        }
293    }
298    protected function userIsSuperAdmin(): void
299    {
300        if (!$this->currentUser->isSuperAdmin()) {
301            throw new UnauthorizedHttpException(challenge: 'User is not super admin.');
302        }
303    }
308    protected function userHasGroupPermission(): void
309    {
310        if (!$this->currentUser->isLoggedIn()) {
311            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
312        }
313
314        $currentUser = $this->currentUser;
315        if (
316            !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_ADD->value)
317            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_EDIT->value)
318            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_DELETE->value)
319            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::GROUP_EDIT->value)
320        ) {
321            throw new ForbiddenException(message: 'User has no group permission.');
322        }
323    }
328    protected function userHasUserPermission(): void
329    {
330        if (!$this->currentUser->isLoggedIn()) {
331            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
332        }
333
334        $currentUser = $this->currentUser;
335        if (
336            !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_ADD->value)
337            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_EDIT->value)
338            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_DELETE->value)
339        ) {
340            throw new ForbiddenException(message: 'User has no user permission.');
341        }
342    }
347    protected function userHasPermission(PermissionType $permissionType): void
348    {
349        if (!$this->currentUser->isLoggedIn()) {
350            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
351        }
352
353        $currentUser = $this->currentUser;
354        if (!$currentUser?->perm->hasPermission($currentUser->getUserId(), $permissionType->value)) {
355            throw new ForbiddenException(message: sprintf('User has no "%s" permission.', $permissionType->name));
356        }
357    }
364    protected function userHasAnyPermission(PermissionType ...$permissionTypes): void
365    {
366        if (!$this->currentUser->isLoggedIn()) {
367            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
368        }
369
370        $currentUser = $this->currentUser;
371        foreach ($permissionTypes as $permissionType) {
372            if ($currentUser->perm->hasPermission($currentUser->getUserId(), $permissionType->value)) {
373                return;
374            }
375        }
376
377        throw new ForbiddenException(message: sprintf('User has none of the required permissions: %s.', implode(', ', array_map(
378            static fn(PermissionType $type): string => $type->name,
379            $permissionTypes,
380        ))));
381    }
389    protected function verifySessionCsrfToken(string $page, #[\SensitiveParameter] string $requestToken): bool
390    {
391        if ($requestToken === '') {
392            return false;
393        }
394
395        $sessionKey = sprintf('pmf-csrf-token.%s', $page);
396        $storedToken = $this->session->get($sessionKey);
397
398        if (!$storedToken instanceof Token) {
399            return false;
400        }
401
402        if (time() > $storedToken->getExpiry()) {
403            $this->session->remove($sessionKey);
404            return false;
405        }
406
407        return hash_equals($storedToken->getSessionToken(), $requestToken);
408    }
414    protected function captchaCodeIsValid(Request $request): bool
415    {
416        $captcha = Captcha::getInstance($this->configuration);
417        $captcha->setUserIsLoggedIn($this->currentUser->isLoggedIn());
418
419        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
420
421        $code = Filter::filterVar($data->captcha ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
422        if ($this->configuration->get(item: 'security.enableGoogleReCaptchaV2')) {
423            $code = Filter::filterVar($data->{'g-recaptcha-response'} ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
424        }
425
426        return $captcha->checkCaptchaCode((string) $code);
427    }
429    public function isApiEnabled(): bool
430    {
431        return (bool) $this->configuration->get(item: 'api.enableAccess');
432    }
434    public function addExtension(ExtensionInterface $extension): void
435    {
436        $this->twigExtensions[] = $extension;
437    }
439    public function addFilter(TwigFilter $twigFilter): void
440    {
441        $this->twigFilters[] = $twigFilter;
442    }
444    protected function getRateLimiter(): ?RateLimiter
445    {
446        if (!$this->container->has('phpmyfaq.http.rate-limiter')) {
447            return null;
448        }
449
450        $rateLimiter = $this->container->get('phpmyfaq.http.rate-limiter');
451
452        return $rateLimiter instanceof RateLimiter ? $rateLimiter : null;
453    }
455    private function createFallbackContainer(): ContainerBuilder
456    {
457        $containerBuilder = new ContainerBuilder();
458        $phpFileLoader = new PhpFileLoader($containerBuilder, new FileLocator(__DIR__));
459        try {
460            $phpFileLoader->load(resource: '../../services.php');
461        } catch (\Exception $exception) {
462            error_log($exception->getMessage());
463        }
464
465        // Register Forms services
466        FormsServiceProvider::register($containerBuilder);
467
468        return $containerBuilder;
469    }