Lines 71.86% 442 / 615
Methods 67.64% 23 / 34
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 add 98.57% 69 / 70 0.00% 0 / 1 7
 solution 100.00% 15 / 15 100.00% 1 / 1 3
 contentRedirect 100.00% 16 / 16 100.00% 1 / 1 6
 show 19.47% 37 / 190 0.00% 0 / 1 601.65
 prepareCommentsData 95.83% 23 / 24 0.00% 0 / 1 3
 [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
58final class FaqController extends AbstractFrontController
59{
60    /* @mago-expect lint:excessive-parameter-list - the controller dependencies are injected explicitly */
61    public function __construct(
62        private readonly UserSession $faqSession,
63        private readonly CaptchaInterface $captcha,
64        private readonly CaptchaHelperInterface $captchaHelper,
65        private readonly Faq $faq,
66        private readonly Category $category,
67        private readonly Bookmark $bookmark,
68        private readonly Date $date,
69        private readonly Mail $mail,
70        private readonly Gravatar $gravatar,
71    ) {
72        parent::__construct();
73    }
74
75    /**
76     * Displays the form to add a new FAQ
77     *
78     * @throws Exception|LoaderError|\Exception
79     */
80    #[Route(path: '/add-faq.html', name: 'public.faq.add', methods: ['GET'])]
81    public function add(Request $request): Response
82    {
83        $this->faqSession->setCurrentUser($this->currentUser);
84        $this->faqSession->userTracking('new_entry', 0);
85
86        // Get current groups
87        $currentGroups = $this->currentUser->perm->getUserGroups($this->currentUser->getUserId());
88
89        $faqCreationService = new FaqCreationService($this->configuration, $this->currentUser, $currentGroups);
90
91        if (!$faqCreationService->canUserAddFaq()) {
92            if ($this->currentUser->getUserId() === -1) {
93                return new RedirectResponse($this->configuration->getDefaultUrl() . 'login');
94            }
95
96            return new RedirectResponse($this->configuration->getDefaultUrl());
97        }
98
99        $selectedQuestion = Filter::filterVar($request->query->get('question'), FILTER_VALIDATE_INT);
100        $selectedCategory = Filter::filterVar($request->query->get('cat'), FILTER_VALIDATE_INT, -1);
101
102        $faqData = $faqCreationService->prepareAddFaqData($selectedQuestion, $selectedCategory);
103
104        // Add Twig filter
105        $this->addFilter(new TwigFilter('repeat', static fn(
106            mixed $string,
107            mixed $times,
108        ): string => str_repeat((string) $string, max(0, (int) $times))));
109
110        // Prepare template variables
111        $templateVars = [
112            ...$this->getHeader($request),
113            'title' => sprintf(
114                '%s - %s',
115                Translation::getString(key: 'msgAddContent'),
116                $this->configuration->getTitle(),
117            ),
118            'metaDescription' => sprintf(
119                '%s | %s',
120                Translation::getString(key: 'msgNewContentHeader'),
121                $this->configuration->getTitle(),
122            ),
123            'msgNewContentHeader' => Translation::get(key: 'msgNewContentHeader'),
124            'msgNewContentAddon' => Translation::get(key: 'msgNewContentAddon'),
125            'lang' => $this->configuration->getLanguage()->getLanguage(),
126            'openQuestionID' => $faqData['selectedQuestion'],
127            'defaultContentMail' => $faqCreationService->getDefaultUserEmail(),
128            'defaultContentName' => $faqCreationService->getDefaultUserName(),
129            'msgNewContentName' => Translation::get(key: 'msgNewContentName'),
130            'msgNewContentMail' => Translation::get(key: 'msgNewContentMail'),
131            'msgNewContentCategory' => Translation::get(key: 'msgNewContentCategory'),
132            'selectedCategory' => $faqData['selectedCategory'],
133            'categories' => $faqData['categories'],
134            'msgNewContentTheme' => Translation::get(key: 'msgNewContentTheme'),
135            'readonly' => $faqData['readonly'],
136            'question' => $faqData['question'],
137            'msgNewContentArticle' => Translation::get(key: 'msgNewContentArticle'),
138            'msgNewContentKeywords' => Translation::get(key: 'msgNewContentKeywords'),
139            'msgNewContentLink' => Translation::get(key: 'msgNewContentLink'),
140            'captchaFieldset' => $this->captchaHelper->renderCaptcha(
141                $this->captcha,
142                'add',
143                Translation::getString(key: 'msgCaptcha'),
144                $this->currentUser->isLoggedIn(),
145            ),
146            'msgNewContentSubmit' => Translation::get(key: 'msgNewContentSubmit'),
147            'enableWysiwygEditor' => $this->configuration->get('main.enableWysiwygEditorFrontend'),
148            'currentTimestamp' => $request->server->get('REQUEST_TIME'),
149            'msgSeparateKeywordsWithCommas' => Translation::get(key: 'msgSeparateKeywordsWithCommas'),
150            'noCategories' => $faqData['noCategories'],
151            'msgFormDisabledDueToMissingCategories' => Translation::get(key: 'msgFormDisabledDueToMissingCategories'),
152            'displayFullForm' => $faqData['displayFullForm'],
153        ];
154
155        // Collect data for displaying form
156        $formData = is_array($faqData['formData'] ?? null) ? $faqData['formData'] : [];
157        foreach ($formData as $input) {
158            if (!$input instanceof \stdClass) {
159                continue;
160            }
161
162            $active = sprintf('id%d_active', (int) $input->input_id);
163            $label = sprintf('id%d_label', (int) $input->input_id);
164            $required = sprintf('id%d_required', (int) $input->input_id);
165            $templateVars[$active] = (int) $input->input_active !== 0;
166            $templateVars[$label] = $input->input_label;
167            $templateVars[$required] = (int) $input->input_required !== 0 ? 'required' : '';
168        }
169
170        $this->addExtension(new AttributeExtension(LanguageCodeTwigExtension::class));
171        return $this->render('add.twig', $templateVars);
172    }
173
174    /**
175     * Redirects solution_id URLs to the actual FAQ page
176     *
177     * @throws Exception|\Exception
178     */
179    #[Route(path: '/solution_id_{solutionId}.html', name: 'public.faq.solution', methods: ['GET'])]
180    public function solution(Request $request): Response
181    {
182        $solutionId = Filter::filterVar($request->attributes->get('solutionId'), FILTER_VALIDATE_INT, 0);
183
184        if ($solutionId === 0) {
185            return new Response('', Response::HTTP_NOT_FOUND);
186        }
187
188        $faqData = $this->faq->getIdFromSolutionId($solutionId);
189
190        if ($faqData === []) {
191            return new Response('', Response::HTTP_NOT_FOUND);
192        }
193
194        $slug = TitleSlugifier::slug((string) $faqData['question']);
195
196        // Redirect to the canonical FAQ URL
197        $url = sprintf(
198            '/content/%d/%d/%s/%s.html',
199            (int) $faqData['category_id'],
200            (int) $faqData['id'],
201            (string) $faqData['lang'],
202            $slug,
203        );
204
205        return new RedirectResponse($url, Response::HTTP_MOVED_PERMANENTLY);
206    }
207
208    /**
209     * Redirects short content URLs to the full FAQ page
210     *
211     * @throws Exception|\Exception
212     */
213    #[Route(path: '/content/{faqId}/{faqLang}', name: 'public.faq.redirect', methods: ['GET'])]
214    public function contentRedirect(Request $request): Response
215    {
216        $faqId = Filter::filterVar($request->attributes->get('faqId'), FILTER_VALIDATE_INT, 0);
217        $faqLang = Filter::filterVar($request->attributes->get('faqLang'), FILTER_SANITIZE_SPECIAL_CHARS, '');
218
219        if ($faqId === 0 || $faqLang === '') {
220            return new Response('', Response::HTTP_NOT_FOUND);
221        }
222
223        // Query the FAQ data directly for the specified language
224        $result = $this->faq->getFaqResult($faqId, $faqLang);
225
226        if ($this->configuration->getDb()->numRows($result) === 0) {
227            return new Response('', Response::HTTP_NOT_FOUND);
228        }
229
230        $row = $this->configuration->getDb()->fetchObject($result);
231        if (!$row instanceof \stdClass) {
232            return new Response('', Response::HTTP_NOT_FOUND);
233        }
234
235        $categoryId = $this->category->getCategoryIdFromFaq($faqId);
236
237        if ($categoryId === 0) {
238            return new Response('', Response::HTTP_NOT_FOUND);
239        }
240
241        $slug = TitleSlugifier::slug((string) $row->thema);
242
243        // Redirect to the canonical FAQ URL
244        $url = sprintf('/content/%d/%d/%s/%s.html', $categoryId, $faqId, $faqLang, $slug);
245
246        return new RedirectResponse($url, Response::HTTP_MOVED_PERMANENTLY);
247    }
248
249    /**
250     * Displays a single FAQ article with comments, ratings, and related content
251     *
252     * @throws Exception|LoaderError|\Exception
253     */
254    #[Route(path: '/content/{categoryId}/{faqId}/{faqLang}/{slug}.html', name: 'public.faq.show', methods: ['GET'])]
255    public function show(Request $request): Response
256    {
257        $this->faqSession->setCurrentUser($this->currentUser);
258
259        // Get parameters
260        $categoryId = Filter::filterVar($request->attributes->get('categoryId'), FILTER_VALIDATE_INT, 0);
261
262        // Get faqId from route attributes (new routes) or query parameters (legacy/backward compatibility)
263        $faqId = Filter::filterVar($request->attributes->get('faqId'), FILTER_VALIDATE_INT, 0);
264
265        // Get language from route parameter (for /content/ URLs)
266        $requestedLanguage =
267            Filter::filterVar(
268                $request->attributes->get('language'),
269                FILTER_SANITIZE_SPECIAL_CHARS,
270            ) ?? Filter::filterVar(
271                $request->attributes->get('faqLang'),
272                FILTER_SANITIZE_SPECIAL_CHARS,
273            ) ?? $this->configuration->getLanguage()->getLanguage();
274
275        // Temporarily set the language in session for this request
276        $originalLanguage = $this->session->get('lang');
277        if ($requestedLanguage !== $originalLanguage) {
278            $this->session->set('lang', $requestedLanguage);
279            // Update the static language variable
280            Language::$language = $requestedLanguage;
281        }
282
283        $solutionId = Filter::filterVar($request->query->get('solution_id'), FILTER_VALIDATE_INT);
284        $highlight = Filter::filterVar($request->query->get('highlight'), FILTER_SANITIZE_SPECIAL_CHARS);
285        $bookmarkAction = Filter::filterVar($request->query->get('bookmark_action'), FILTER_SANITIZE_SPECIAL_CHARS);
286
287        // Initialize core objects
288        $faq = new Faq($this->configuration);
289        $currentGroups = $this->currentUser->perm->getUserGroups($this->currentUser->getUserId());
290
291        // Handle bookmarks
292        if ($bookmarkAction === 'add' && $faqId > 0) {
293            $this->bookmark->add($faqId);
294        }
295
296        if ($bookmarkAction === 'remove' && $faqId > 0) {
297            $this->bookmark->remove($faqId);
298        }
299
300        // Create a detail service
301        $faqDisplayService = new FaqDisplayService(
302            $this->configuration,
303            $this->currentUser,
304            $currentGroups,
305            $faq,
306            $this->category,
307        );
308
309        // Load FAQ data
310        $faqId = $faqDisplayService->loadFaq($faqId, $solutionId);
311
312        // Do not disclose a FAQ the requester may not see. loadFaq() also returns
313        // non-permitted, inactive, not yet published and expired records, keeping their title,
314        // solution ID, author and update date intact and only replacing the answer with a
315        // placeholder. Checked before the visit is tracked so hidden records leave no trace.
316        if (!$faq->isFaqRecordVisible()) {
317            return new Response('', Response::HTTP_NOT_FOUND);
318        }
319
320        // Track visit
321        $this->faqSession->userTracking('article_view', $faqId);
322        $faqVisits = new Visits($this->configuration);
323        $faqVisits->logViews($faqId);
324
325        // Check if category and FAQ are linked
326        if (!$this->category->categoryHasLinkToFaq($faqId, $categoryId)) {
327            return new Response('', Response::HTTP_NOT_FOUND);
328        }
329
330        // Process content
331        $currentUrl = sprintf('//%s%s', $request->getHost(), $request->getRequestUri());
332        $question = $faqDisplayService->processQuestion($highlight);
333        $answer = $faqDisplayService->processAnswer($currentUrl, $highlight);
334
335        // Get related data
336        $attachmentList = $faqDisplayService->getAttachmentList($faqId);
337        $renderedCategoryPath = $faqDisplayService->getRenderedCategoryPath($faqId);
338        $relatedFaqs = $faqDisplayService->getRelatedFaqs($faqId);
339        $numComments = $faqDisplayService->getNumberOfComments();
340
341        // Do not fetch or render comments when the FAQ record is not accessible to the
342        // current requester (getFaq() flags a denied/non-visible record with solution_id 42).
343        $isFaqAccessible =
344            ($faq->faqRecord['solution_id'] ?? null) !== null && 42 !== (int) $faq->faqRecord['solution_id'];
345        $comments = $isFaqAccessible ? $faqDisplayService->getCommentsData($faqId) : [];
346        $availableLanguages = $faqDisplayService->getAvailableLanguages((int) $faq->faqRecord['id']);
347        $tagsHtml = $faqDisplayService->getTagsHtml($faqId);
348
349        // Generate language URLs with SEO slugs
350        $languageUrls = [];
351        foreach ($availableLanguages as $language) {
352            $url = sprintf(
353                '%scontent/%d/%d/%s/%s.html',
354                $this->configuration->getDefaultUrl(),
355                $categoryId,
356                $faqId,
357                $language,
358                TitleSlugifier::slug($question),
359            );
360            $link = new Link($url, $this->configuration);
361            $link->setTitle($question);
362            $languageUrls[$language] = $link->toString();
363        }
364
365        // Comment permissions
366        $expired = $faqDisplayService->isExpired();
367
368        $commentMessage = sprintf(
369            '%s<a href="#" data-bs-toggle="modal" data-bs-target="#pmf-modal-add-comment">%s</a>',
370            Translation::getString(key: 'msgYouCan'),
371            Translation::getString(key: 'msgWriteComment'),
372        );
373        if (
374            -1 === $this->currentUser->getUserId() && !$this->configuration->get('records.allowCommentsForGuests')
375            || $faq->faqRecord['active'] === 'no'
376            || 'n' === $faq->faqRecord['comment']
377            || $expired
378        ) {
379            $commentMessage = Translation::get(key: 'msgWriteNoComment');
380        }
381
382        // Services for social sharing
383        $faqServices = new Services($this->configuration);
384        $faqServices->setCategoryId($categoryId);
385        $faqServices->setFaqId($faqId);
386        $faqServices->setLanguage($this->configuration->getLanguage()->getLanguage());
387        $faqServices->setQuestion($question);
388
389        // Author visibility (GDPR)
390        $author = $this->currentUser->getUserVisibilityByEmail((string) $faq->faqRecord['email'])
391            ? $faq->faqRecord['author']
392            : 'n/a';
393
394        // SEO
395        $seo = new Seo($this->configuration);
396        $seoEntity = new SeoEntity();
397        $seoEntity
398            ->setSeoType(SeoType::FAQ)
399            ->setReferenceId((int) $faq->faqRecord['id'])
400            ->setReferenceLanguage((string) $faq->faqRecord['lang']);
401        $seoData = $seo->get($seoEntity);
402
403        // Date formatter
404        $date = new Date($this->configuration);
405
406        // Build template variables
407        $templateVars = [
408            ...$this->getHeader($request),
409            'title' => sprintf('%s - %s', $seoData->getTitle() ?? $question, $this->configuration->getTitle()),
410            'metaDescription' => $seoData->getDescription(),
411            'solutionId' => $faq->faqRecord['solution_id'],
412            'solutionIdLink' => './solution_id_' . (string) ($faq->faqRecord['solution_id'] ?? '') . '.html',
413            'breadcrumb' => $this->category->getPathWithStartpage($categoryId, '/', true),
414            'question' => $question,
415            'answer' => $answer,
416            'attachmentList' => $attachmentList,
417            'faqDate' => $date->format((string) $faq->faqRecord['created']),
418            'faqLastChangeDate' => $date->format((string) $faq->faqRecord['date']),
419            'faqAuthor' => $author,
420            'msgPdf' => Translation::get(key: 'msgPDF'),
421            'msgPrintFaq' => Translation::get(key: 'msgPrintArticle'),
422            'enableSendToFriend' => true === $this->configuration->get('main.enableSendToFriend'),
423            'msgShareText' => Translation::get(key: 'msgShareText'),
424            'msgShareViaWhatsapp' => Translation::get(key: 'msgShareViaWhatsapp'),
425            'msgShareFAQ' => Translation::get(key: 'msgShareFAQ'),
426            'linkToPdf' => $faqServices->getPdfLink(),
427            'msgAverageVote' => Translation::get(key: 'msgAverageVote'),
428            'renderVotingResult' => $faqDisplayService->getRating($faqId),
429            'csrfTokenVoting' => Token::getInstance($this->session)->getTokenString('voting'),
430            'languageUrls' => $languageUrls,
431            'currentLanguage' => $faq->faqRecord['lang'],
432            'msgVoteBad' => Translation::get(key: 'msgVoteBad'),
433            'msgVoteGood' => Translation::get(key: 'msgVoteGood'),
434            'msgVoteSubmit' => Translation::get(key: 'msgVoteSubmit'),
435            'msgWriteComment' => Translation::get(key: 'msgWriteComment'),
436            'id' => $faqId,
437            'lang' => $faq->faqRecord['lang'],
438            'msgNewContentName' => Translation::get(key: 'msgNewContentName'),
439            'msgNewContentMail' => Translation::get(key: 'msgNewContentMail'),
440            'defaultContentMail' => $this->currentUser->getUserId() > 0
441            && is_string($contentMail = $this->currentUser->getUserData('email'))
442                ? $contentMail
443                : '',
444            'defaultContentName' => $this->currentUser->getUserId() > 0
445            && is_string($contentName = $this->currentUser->getUserData('display_name'))
446                ? $contentName
447                : '',
448            'msgYourComment' => Translation::get(key: 'msgYourComment'),
449            'msgCancel' => Translation::get(key: 'ad_gen_cancel'),
450            'msgNewContentSubmit' => Translation::get(key: 'msgNewContentSubmit'),
451            'csrfTokenAddComment' => Token::getInstance($this->session)->getTokenString('add-comment'),
452            'enableCommentEditor' => true === $this->configuration->get('main.enableCommentEditor'),
453            'captchaFieldset' => $this->captchaHelper->renderCaptcha(
454                $this->captcha,
455                'writecomment',
456                Translation::getString(key: 'msgCaptcha'),
457                $this->currentUser->isLoggedIn(),
458            ),
459            'comments' => $this->prepareCommentsData($comments),
460            'msgShowMore' => Translation::get(key: 'msgShowMore'),
461            'msgAboutFAQ' => Translation::get(key: 'msgAboutFAQ'),
462            'userId' => $this->currentUser->getUserId(),
463            'permissionEditFaq' => $this->currentUser->perm->hasPermission(
464                $this->currentUser->getUserId(),
465                PermissionType::FAQ_EDIT->value,
466            ),
467            'ad_entry_edit_1' => Translation::get(key: 'ad_entry_edit_1'),
468            'ad_entry_edit_2' => Translation::get(key: 'ad_entry_edit_2'),
469            'bookmarkAction' => $bookmarkAction ?? '',
470            'msgBookmarkAdded' => Translation::get(key: 'msgBookmarkAdded'),
471            'msgBookmarkRemoved' => Translation::get(key: 'msgBookmarkRemoved'),
472            'csrfTokenRemoveBookmark' => Token::getInstance($this->session)->getTokenString('delete-bookmark'),
473            'csrfTokenAddBookmark' => Token::getInstance($this->session)->getTokenString('add-bookmark'),
474            'numberOfComments' => sprintf(
475                '%d %s',
476                $isFaqAccessible ? $numComments[$faqId] ?? 0 : 0,
477                Translation::getString(key: 'msgComments'),
478            ),
479            'writeCommentMsg' => $commentMessage,
480        ];
481
482        // Add conditional variables
483        if (-1 !== $this->currentUser->getUserId()) {
484            $templateVars['bookmarkIcon'] = $this->bookmark->isFaqBookmark($faqId)
485                ? 'bi bi-bookmark-fill'
486                : 'bi bi-bookmark';
487            $templateVars['msgAddBookmark'] = $this->bookmark->isFaqBookmark($faqId)
488                ? Translation::get(key: 'removeBookmark')
489                : Translation::get(key: 'msgAddBookmark');
490            $templateVars['isFaqBookmark'] = $this->bookmark->isFaqBookmark($faqId);
491        }
492
493        if ($availableLanguages !== [] && count($availableLanguages) > 1) {
494            $templateVars['msgChangeLanguage'] = Translation::get(key: 'msgLanguageSubmit');
495        }
496
497        if (
498            $this->currentUser->perm->hasPermission($this->currentUser->getUserId(), PermissionType::FAQ_EDIT->value)
499            && array_key_exists('notes', $faq->faqRecord)
500            && $faq->faqRecord['notes'] !== ''
501        ) {
502            $templateVars['notesHeader'] = Translation::get(key: 'ad_admin_notes');
503            $templateVars['notes'] = $faq->faqRecord['notes'];
504        }
505
506        if ($tagsHtml !== '-') {
507            $templateVars['renderTagsHeader'] = Translation::get(key: 'msg_tags');
508            $templateVars['renderTags'] = $tagsHtml;
509        }
510
511        if ($renderedCategoryPath !== '') {
512            $templateVars['renderRelatedCategoriesHeader'] = Translation::get(key: 'msgArticleCategories');
513            $templateVars['renderRelatedCategories'] = $renderedCategoryPath;
514        }
515
516        if ($relatedFaqs !== '') {
517            $templateVars['renderRelatedArticlesHeader'] = Translation::get(key: 'msg_related_articles');
518            $templateVars['renderRelatedArticles'] = $relatedFaqs;
519        }
520
521        $this->addExtension(new AttributeExtension(LanguageCodeTwigExtension::class));
522        return $this->render('faq.twig', $templateVars);
523    }
524
525    /**
526     * Prepares comment data for the Twig macro
527     *
528     * @param array<array-key, mixed> $comments Array of Comment objects
529     * @throws \Exception
530     * @return array<string, array<array-key, mixed>>
531     */
532    private function prepareCommentsData(array $comments): array
533    {
534        $preparedComments = [];
535        $gravatarImages = [];
536        $safeEmails = [];
537        $formattedDates = [];
538
539        foreach ($comments as $comment) {
540            if (!$comment instanceof Comment) {
541                continue;
542            }
543
544            $commentId = $comment->getId();
545            $preparedComments[] = [
546                'id' => $commentId,
547                'email' => $comment->getEmail(),
548                'username' => Strings::htmlentities($comment->getUsername()),
549                'date' => $comment->getDate(),
550                'comment' => Utils::parseUrl($comment->getComment()),
551            ];
552
553            $gravatarImages[$commentId] = $this->gravatar->getImage($comment->getEmail(), ['class' => 'img-thumbnail']);
554            $safeEmails[$commentId] = $this->mail->safeEmail($comment->getEmail());
555            $formattedDates[$commentId] = $this->date->format($comment->getDate());
556        }
557
558        return [
559            'comments' => $preparedComments,
560            'gravatarImages' => $gravatarImages,
561            'safeEmails' => $safeEmails,
562            'formattedDates' => $formattedDates,
563        ];
564    }
565}

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    }