Lines 97.53% 872 / 894
Methods 62.16% 23 / 37
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 index 100.00% 20 / 20 100.00% 1 / 1 1
 add 98.11% 52 / 53 0.00% 0 / 1 3
 addInCategory 98.27% 57 / 58 0.00% 0 / 1 3
 edit 96.00% 96 / 100 0.00% 0 / 1 11
 copy 98.11% 52 / 53 0.00% 0 / 1 2
 translate 98.11% 52 / 53 0.00% 0 / 1 2
 answer 98.41% 62 / 63 0.00% 0 / 1 3
 getBaseTemplateVars 100.00% 51 / 51 100.00% 1 / 1 1
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] initializeFromContainer 80.00% 4 / 5 0.00% 0 / 1 2.03
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] render 100.00% 4 / 4 100.00% 1 / 1 1
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] getHeader 97.91% 47 / 48 0.00% 0 / 1 3
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] getSecondLevelEntries 100.00% 156 / 156 100.00% 1 / 1 6
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] getPageFlags 100.00% 87 / 87 100.00% 1 / 1 61
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] getGravatarImage 100.00% 8 / 8 100.00% 1 / 1 4
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] userHasPermission 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] getFooter 100.00% 12 / 12 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] setContainer 100.00% 2 / 2 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] 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
59final class FaqController extends AbstractAdministrationController
60{
61    /* @mago-expect lint:excessive-parameter-list - the controller dependencies are injected explicitly; a service split is planned with the admin rework */
62    public function __construct(
63        private readonly Comments $comments,
64        private readonly Faq $faq,
65        private readonly Tags $tags,
66        private readonly Seo $seo,
67        private readonly CategoryHelper $categoryHelper,
68        private readonly UserHelper $userHelper,
69        private readonly FaqPermission $faqPermission,
70        private readonly Changelog $changelog,
71        private readonly Question $question,
72    ) {
73        parent::__construct();
74    }
75
76    /**
77     * @throws Exception
78     * @throws LoaderError
79     * @throws \Exception
80     */
81    #[Route(path: '/faqs', name: 'admin.faqs', methods: ['GET'])]
82    public function index(Request $request): Response
83    {
84        $this->userHasPermission(PermissionType::FAQ_ADD);
85        $this->userHasPermission(PermissionType::FAQ_APPROVE);
86        $this->userHasPermission(PermissionType::FAQ_EDIT);
87        $this->userHasPermission(PermissionType::FAQ_DELETE);
88
89        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
90
91        $category = new Category($this->configuration, $currentAdminGroups, true);
92        $category->setUser($currentAdminUser);
93        $category->setGroups($currentAdminGroups);
94        $category->buildCategoryTree();
95
96        $categoryRelation = new Relation($this->configuration, $category);
97        $categoryRelation->setGroups($currentAdminGroups);
98
99        return $this->render('@admin/content/faq.overview.twig', [
100            ...$this->getHeader($request),
101            ...$this->getFooter(),
102            'csrfTokenSearch' => Token::getInstance($this->session)->getTokenInput('pmf-csrf-token'),
103            'csrfTokenOverview' => Token::getInstance($this->session)->getTokenString('pmf-csrf-token'),
104            'categories' => $category->getCategoryTree(),
105            'numberOfRecords' => $categoryRelation->getNumberOfFaqsPerCategory(),
106            'numberOfComments' => $this->comments->getNumberOfCommentsByCategory(),
107        ]);
108    }
109
110    /**
111     * @throws Exception
112     * @throws LoaderError
113     * @throws \Exception
114     * @todo refactor Twig template variables
115     */
116    #[Route(path: '/faq/add', name: 'admin.faq.add', methods: ['GET'])]
117    public function add(Request $request): Response
118    {
119        $this->userHasPermission(PermissionType::FAQ_ADD);
120
121        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
122
123        $category = new Category($this->configuration, $currentAdminGroups, true);
124        $category->setUser($currentAdminUser);
125        $category->setGroups($currentAdminGroups);
126        $category->buildCategoryTree();
127
128        $this->categoryHelper->setCategory($category);
129
130        $this->adminLog->log($this->currentUser, AdminLogType::FAQ_ADD->value);
131        $categories = [];
132
133        $faqData = [
134            'id' => 0,
135            'lang' => $this->configuration->getLanguage()->getLanguage(),
136            'revision_id' => 0,
137            'author' => $this->currentUser->getUserData('display_name'),
138            'email' => $this->currentUser->getUserData('email'),
139            'comment' => $this->configuration->get(item: 'records.defaultAllowComments') ? 'checked' : null,
140        ];
141
142        $this->addExtension(new AttributeExtension(IsoDateTwigExtension::class));
143        $this->addExtension(new AttributeExtension(UserNameTwigExtension::class));
144        $this->addExtension(new AttributeExtension(FormatBytesTwigExtension::class));
145        return $this->render('@admin/content/faq.editor.twig', [
146            ...$this->getHeader($request),
147            ...$this->getFooter(),
148            ...$this->getBaseTemplateVars(),
149            'header' => Translation::get(key: 'msgAddFAQ'),
150            'editExistingFaq' => false,
151            'faqRevisionId' => 0,
152            'faqData' => $faqData,
153            'openQuestionId' => 0,
154            'notifyUser' => '',
155            'notifyEmail' => '',
156            'categoryTree' => $category->getCategoryTree(),
157            'selectedCategories' => $categories,
158            'languageOptions' => LanguageHelper::renderSelectLanguage($faqData['lang'], false, [], 'lang'),
159            'attachments' => [],
160            'allGroups' => true,
161            'restrictedGroups' => false,
162            'groupPermissionOptions' => $this->currentUser->perm instanceof MediumPermission
163                ? $this->currentUser->perm->getAllGroupsOptions([-1], $this->currentUser)
164                : '',
165            'allUsers' => true,
166            'restrictedUsers' => false,
167            'userSelection' => $this->userHelper->getAllUsersForTemplate(-1, true),
168            'changelogs' => [],
169            'hasPermissionForApprove' => $this->currentUser?->perm->hasPermission(
170                $this->currentUser->getUserId(),
171                PermissionType::FAQ_APPROVE->value,
172            ),
173            'isActive' => null,
174            'isInActive' => 'checked',
175            'nextSolutionId' => $this->faq->getNextSolutionId(),
176            'nextFaqId' => $this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faqdata', 'id'),
177        ]);
178    }
179
180    #[Route(path: '/faq/add/{categoryId}/{categoryLanguage}', name: 'admin.faq.add.category', methods: ['GET'])]
181    public function addInCategory(Request $request): Response
182    {
183        $this->userHasPermission(PermissionType::FAQ_ADD);
184
185        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
186
187        $category = new Category($this->configuration, $currentAdminGroups, true);
188        $category->setUser($currentAdminUser);
189        $category->setGroups($currentAdminGroups);
190        $category->buildCategoryTree();
191
192        $categoryId = (int) Filter::filterVar($request->attributes->get('categoryId'), FILTER_VALIDATE_INT);
193        $categoryLanguage = Filter::filterVar(
194            $request->attributes->get('categoryLanguage'),
195            FILTER_SANITIZE_SPECIAL_CHARS,
196            '',
197        );
198
199        $this->categoryHelper->setCategory($category);
200
201        $this->adminLog->log($this->currentUser, AdminLogType::FAQ_ADD->value);
202
203        $faqData = [
204            'id' => 0,
205            'lang' => $categoryLanguage,
206            'revision_id' => 0,
207            'author' => $this->currentUser->getUserData('display_name'),
208            'email' => $this->currentUser->getUserData('email'),
209            'comment' => $this->configuration->get(item: 'records.defaultAllowComments') ? 'checked' : null,
210        ];
211
212        $this->addExtension(new AttributeExtension(IsoDateTwigExtension::class));
213        $this->addExtension(new AttributeExtension(UserNameTwigExtension::class));
214        $this->addExtension(new AttributeExtension(FormatBytesTwigExtension::class));
215        return $this->render('@admin/content/faq.editor.twig', [
216            ...$this->getHeader($request),
217            ...$this->getFooter(),
218            ...$this->getBaseTemplateVars(),
219            'header' => Translation::get(key: 'msgAddFAQ'),
220            'editExistingFaq' => false,
221            'faqRevisionId' => 0,
222            'faqData' => $faqData,
223            'openQuestionId' => 0,
224            'notifyUser' => '',
225            'notifyEmail' => '',
226            'categoryTree' => $category->getCategoryTree(),
227            'selectedCategories' => $categoryId,
228            'languageOptions' => LanguageHelper::renderSelectLanguage($faqData['lang'], false, [], 'lang'),
229            'attachments' => [],
230            'allGroups' => true,
231            'restrictedGroups' => false,
232            'groupPermissionOptions' => $this->currentUser->perm instanceof MediumPermission
233                ? $this->currentUser->perm->getAllGroupsOptions([-1], $this->currentUser)
234                : '',
235            'allUsers' => true,
236            'restrictedUsers' => false,
237            'userSelection' => $this->userHelper->getAllUsersForTemplate(-1, true),
238            'changelogs' => [],
239            'hasPermissionForApprove' => $this->currentUser?->perm->hasPermission(
240                $this->currentUser->getUserId(),
241                PermissionType::FAQ_APPROVE->value,
242            ),
243            'isActive' => null,
244            'isInActive' => 'checked',
245            'nextSolutionId' => $this->faq->getNextSolutionId(),
246            'nextFaqId' => $this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faqdata', 'id'),
247        ]);
248    }
249
250    /**
251     * @throws Exception
252     * @throws LoaderError
253     * @throws \Exception
254     * @todo refactor Twig template variables
255     */
256    #[Route(path: '/faq/edit/{faqId}/{faqLanguage}', name: 'admin.faq.edit', methods: ['GET', 'POST'])]
257    public function edit(Request $request): Response
258    {
259        $this->userHasPermission(PermissionType::FAQ_EDIT);
260
261        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
262
263        $faqId = (int) Filter::filterVar($request->attributes->get('faqId'), FILTER_VALIDATE_INT);
264        $faqLanguage = Filter::filterVar($request->attributes->get('faqLanguage'), FILTER_SANITIZE_SPECIAL_CHARS, '');
265        $selectedRevisionId = Filter::filterVar($request->request->get('selectedRevisionId'), FILTER_VALIDATE_INT);
266        if (!$selectedRevisionId) {
267            $selectedRevisionId = null;
268        }
269
270        $category = new Category($this->configuration, $currentAdminGroups, true, $faqLanguage);
271        $category->setUser($currentAdminUser);
272        $category->setGroups($currentAdminGroups);
273        $category->buildCategoryTree();
274
275        $this->categoryHelper->setCategory($category);
276
277        $categoryRelation = new Relation($this->configuration, $category);
278
279        $this->adminLog->log($this->currentUser, AdminLogType::FAQ_EDIT->value . ':' . $faqId);
280
281        $categories = $categoryRelation->getCategories($faqId, $faqLanguage);
282
283        $this->faq->getFaq($faqId, null, true);
284        $faqData = $this->faq->faqRecord;
285
286        if ($selectedRevisionId !== null && $selectedRevisionId !== (int) $faqData['revision_id']) {
287            $this->faq->getFaq($faqId, $selectedRevisionId, true);
288            $faqData = $this->faq->faqRecord;
289            $faqData['revision_id'] = $selectedRevisionId;
290        }
291
292        // Tags
293        $faqData['tags'] = implode(', ', $this->tags->getAllTagsById($faqId));
294
295        // SERP
296        $seoEntity = new SeoEntity();
297        $seoEntity->setSeoType(SeoType::FAQ)->setReferenceId($faqId)->setReferenceLanguage($faqLanguage);
298        $seoData = $this->seo->get($seoEntity);
299        $faqData['serp-title'] = $seoData->getTitle();
300        $faqData['serp-description'] = $seoData->getDescription();
301
302        $attachmentList = AttachmentFactory::fetchByRecordId($this->configuration, $faqId);
303
304        $faqRevision = new Revision($this->configuration);
305        $revisions = $faqRevision->get($faqId, $faqLanguage, (string) $faqData['author']);
306
307        $faqUrl = sprintf(
308            '%scontent/%d/%d/%s/%s.html',
309            $this->configuration->getDefaultUrl(),
310            $category->getCategoryIdFromFaq($faqId),
311            $faqId,
312            $faqLanguage,
313            TitleSlugifier::slug((string) $faqData['title']),
314        );
315
316        // User permissions
317        $userPermission = $this->faqPermission->get(Permission::USER, $faqId);
318        $allUsers = false;
319        $restrictedUsers = true;
320        if (count($userPermission) === 0 || $userPermission[0] === -1) {
321            $allUsers = true;
322            $restrictedUsers = false;
323            $userPermission[0] = -1;
324        }
325
326        // Group permissions
327        $groupPermission = $this->faqPermission->get(Permission::GROUP, $faqId);
328        $allGroups = false;
329        $restrictedGroups = true;
330        if (count($groupPermission) === 0 || $groupPermission[0] === -1) {
331            $allGroups = true;
332            $restrictedGroups = false;
333            $groupPermission[0] = -1;
334        }
335
336        $this->addExtension(new AttributeExtension(IsoDateTwigExtension::class));
337        $this->addExtension(new AttributeExtension(UserNameTwigExtension::class));
338        $this->addExtension(new AttributeExtension(FormatBytesTwigExtension::class));
339
340        return $this->render('@admin/content/faq.editor.twig', [
341            ...$this->getHeader($request),
342            ...$this->getFooter(),
343            ...$this->getBaseTemplateVars(),
344            'header' =>
345                Translation::getString(key: 'ad_entry_edit_1') . ' ' . Translation::getString(key: 'ad_entry_edit_2'),
346            'editExistingFaq' => true,
347            'currentRevision' => sprintf(
348                '%s 1.%d',
349                Translation::getString(key: 'msgRevision'),
350                $selectedRevisionId ?? (int) $faqData['revision_id'],
351            ),
352            'numberOfRevisions' => count($revisions),
353            'faqId' => $faqId,
354            'faqLang' => $faqLanguage,
355            'revisions' => $revisions,
356            'selectedRevisionId' => $selectedRevisionId ?? $faqData['revision_id'],
357            'faqRevisionId' => $faqData['revision_id'],
358            'faqData' => $faqData,
359            'faqUrl' => $faqUrl,
360            'openQuestionId' => 0,
361            'notifyUser' => '',
362            'notifyEmail' => '',
363            'categoryTree' => $category->getCategoryTree(),
364            'selectedCategories' => $categories,
365            'languageOptions' => LanguageHelper::renderSelectLanguage($faqLanguage, false, [], 'lang'),
366            'attachments' => $attachmentList,
367            'allGroups' => $allGroups,
368            'restrictedGroups' => $restrictedGroups,
369            'groupPermissionOptions' => $this->currentUser->perm instanceof MediumPermission
370                ? $this->currentUser->perm->getAllGroupsOptions([-1], $this->currentUser)
371                : '',
372            'allUsers' => $allUsers,
373            'restrictedUsers' => $restrictedUsers,
374            'userSelection' => $this->userHelper->getAllUsersForTemplate(-1, true),
375            'changelogs' => $this->changelog->getByFaqId($faqId),
376            'hasPermissionForApprove' => $this->currentUser?->perm->hasPermission(
377                $this->currentUser->getUserId(),
378                PermissionType::FAQ_APPROVE->value,
379            ),
380            'isActive' => $faqData['active'] === 'yes' ? 'checked' : null,
381            'isInActive' => $faqData['active'] !== 'yes' ? 'checked' : null,
382            'nextSolutionId' => $this->faq->getNextSolutionId(),
383            'nextFaqId' => $this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faqdata', 'id'),
384        ]);
385    }
386
387    /**
388     * @throws Exception
389     * @throws LoaderError
390     * @throws \Exception
391     * @todo refactor Twig template variables
392     */
393    #[Route(path: '/faq/copy/{faqId}/{faqLanguage}', name: 'admin.faq.copy', methods: ['GET'])]
394    public function copy(Request $request): Response
395    {
396        $this->userHasPermission(PermissionType::FAQ_ADD);
397
398        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
399
400        $category = new Category($this->configuration, $currentAdminGroups, true);
401        $category->setUser($currentAdminUser);
402        $category->setGroups($currentAdminGroups);
403        $category->buildCategoryTree();
404
405        $this->categoryHelper->setCategory($category);
406
407        $faqId = (int) Filter::filterVar($request->attributes->get('faqId'), FILTER_VALIDATE_INT);
408        $faqLanguage = Filter::filterVar($request->attributes->get('faqLanguage'), FILTER_SANITIZE_SPECIAL_CHARS, '');
409
410        $this->adminLog->log($this->currentUser, AdminLogType::FAQ_COPY->value . ':' . $faqId);
411
412        $categories = [];
413
414        $this->faq->getFaq($faqId, null, true);
415        $faqData = $this->faq->faqRecord;
416        $faqData['title'] = 'Copy of ' . (string) $faqData['title'];
417
418        $this->addExtension(new AttributeExtension(IsoDateTwigExtension::class));
419        $this->addExtension(new AttributeExtension(UserNameTwigExtension::class));
420        $this->addExtension(new AttributeExtension(FormatBytesTwigExtension::class));
421        return $this->render('@admin/content/faq.editor.twig', [
422            ...$this->getHeader($request),
423            ...$this->getFooter(),
424            ...$this->getBaseTemplateVars(),
425            'header' =>
426                Translation::getString(key: 'ad_entry_edit_1') . ' ' . Translation::getString(key: 'ad_entry_edit_2'),
427            'editExistingFaq' => false,
428            'faqId' => 0,
429            'faqLang' => $faqLanguage,
430            'faqRevisionId' => 0,
431            'faqData' => $faqData,
432            'openQuestionId' => 0,
433            'notifyUser' => '',
434            'notifyEmail' => '',
435            'categoryTree' => $category->getCategoryTree(),
436            'selectedCategories' => $categories,
437            'languageOptions' => LanguageHelper::renderSelectLanguage($faqLanguage, false, [], 'lang'),
438            'attachments' => [],
439            'allGroups' => true,
440            'restrictedGroups' => false,
441            'groupPermissionOptions' => $this->currentUser->perm instanceof MediumPermission
442                ? $this->currentUser->perm->getAllGroupsOptions([-1], $this->currentUser)
443                : '',
444            'allUsers' => true,
445            'restrictedUsers' => false,
446            'userSelection' => $this->userHelper->getAllUsersForTemplate(-1, true),
447            'changelogs' => [],
448            'hasPermissionForApprove' => $this->currentUser?->perm->hasPermission(
449                $this->currentUser->getUserId(),
450                PermissionType::FAQ_APPROVE->value,
451            ),
452            'isActive' => null,
453            'isInActive' => null,
454            'nextSolutionId' => $this->faq->getNextSolutionId(),
455            'nextFaqId' => $this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faqdata', 'id'),
456        ]);
457    }
458
459    /**
460     * @throws Exception
461     * @throws LoaderError
462     * @throws \Exception
463     * @todo refactor Twig template variables
464     */
465    #[Route(path: '/faq/translate/{faqId}/{faqLanguage}', name: 'admin.faq.translate', methods: ['GET'])]
466    public function translate(Request $request): Response
467    {
468        $this->userHasPermission(PermissionType::FAQ_ADD);
469
470        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
471
472        $category = new Category($this->configuration, $currentAdminGroups, true);
473        $category->setUser($currentAdminUser);
474        $category->setGroups($currentAdminGroups);
475        $category->buildCategoryTree();
476
477        $this->categoryHelper->setCategory($category);
478
479        $faqId = (int) Filter::filterVar($request->attributes->get('faqId'), FILTER_VALIDATE_INT);
480        $faqLanguage = Filter::filterVar($request->attributes->get('faqLanguage'), FILTER_SANITIZE_SPECIAL_CHARS, '');
481
482        $this->adminLog->log($this->currentUser, AdminLogType::FAQ_TRANSLATE->value . ':' . $faqId);
483
484        $categories = [];
485
486        $this->faq->getFaq($faqId, null, true);
487        $faqData = $this->faq->faqRecord;
488        $faqData['title'] = 'Translation of ' . (string) $faqData['title'];
489
490        $this->addExtension(new AttributeExtension(IsoDateTwigExtension::class));
491        $this->addExtension(new AttributeExtension(UserNameTwigExtension::class));
492        $this->addExtension(new AttributeExtension(FormatBytesTwigExtension::class));
493        return $this->render('@admin/content/faq.editor.twig', [
494            ...$this->getHeader($request),
495            ...$this->getFooter(),
496            ...$this->getBaseTemplateVars(),
497            'header' =>
498                Translation::getString(key: 'ad_entry_edit_1') . ' ' . Translation::getString(key: 'ad_entry_edit_2'),
499            'editExistingFaq' => false,
500            'faqId' => 0,
501            'faqLang' => $faqLanguage,
502            'faqRevisionId' => 0,
503            'faqData' => $faqData,
504            'openQuestionId' => 0,
505            'notifyUser' => '',
506            'notifyEmail' => '',
507            'categoryTree' => $category->getCategoryTree(),
508            'selectedCategories' => $categories,
509            'languageOptions' => LanguageHelper::renderSelectLanguage($faqLanguage, false, [], 'lang'),
510            'attachments' => [],
511            'allGroups' => true,
512            'restrictedGroups' => false,
513            'groupPermissionOptions' => $this->currentUser->perm instanceof MediumPermission
514                ? $this->currentUser->perm->getAllGroupsOptions([-1], $this->currentUser)
515                : '',
516            'allUsers' => true,
517            'restrictedUsers' => false,
518            'userSelection' => $this->userHelper->getAllUsersForTemplate(-1, true),
519            'changelogs' => [],
520            'hasPermissionForApprove' => $this->currentUser?->perm->hasPermission(
521                $this->currentUser->getUserId(),
522                PermissionType::FAQ_APPROVE->value,
523            ),
524            'isActive' => null,
525            'isInActive' => null,
526            'nextSolutionId' => $this->faq->getNextSolutionId(),
527            'nextFaqId' => $this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faqdata', 'id'),
528        ]);
529    }
530
531    /**
532     * @throws Exception
533     * @throws LoaderError
534     * @throws \Exception
535     * @todo refactor Twig template variables
536     */
537    #[Route(path: '/faq/answer/{questionId}/{faqLanguage}', name: 'admin.faq.answer', methods: ['GET'])]
538    public function answer(Request $request): Response
539    {
540        $this->userHasPermission(PermissionType::FAQ_ADD);
541
542        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
543
544        $category = new Category($this->configuration, $currentAdminGroups, true);
545        $category->setUser($currentAdminUser);
546        $category->setGroups($currentAdminGroups);
547        $category->buildCategoryTree();
548
549        $this->categoryHelper->setCategory($category);
550
551        $questionId = (int) Filter::filterVar($request->attributes->get('questionId'), FILTER_VALIDATE_INT);
552        $faqLanguage = Filter::filterVar($request->attributes->get('faqLanguage'), FILTER_SANITIZE_SPECIAL_CHARS, '');
553
554        $this->adminLog->log($this->currentUser, AdminLogType::FAQ_ANSWER_ADD->value . ':' . $questionId);
555
556        $questionData = $this->question->get($questionId);
557
558        $faqData = [
559            'id' => 0,
560            'lang' => $this->configuration->getLanguage()->getLanguage(),
561            'title' => $questionData['question'] ?? '',
562            'revision_id' => 0,
563            'author' => $this->currentUser->getUserData('display_name'),
564            'email' => $this->currentUser->getUserData('email'),
565            'comment' => $this->configuration->get(item: 'records.defaultAllowComments') ? 'checked' : null,
566        ];
567
568        $categories = [
569            'category_id' => $questionData['category_id'] ?? [],
570            'category_lang' => $faqLanguage,
571        ];
572
573        $this->addExtension(new AttributeExtension(IsoDateTwigExtension::class));
574        $this->addExtension(new AttributeExtension(UserNameTwigExtension::class));
575        $this->addExtension(new AttributeExtension(FormatBytesTwigExtension::class));
576        return $this->render('@admin/content/faq.editor.twig', [
577            ...$this->getHeader($request),
578            ...$this->getFooter(),
579            ...$this->getBaseTemplateVars(),
580            'header' =>
581                Translation::getString(key: 'ad_entry_edit_1') . ' ' . Translation::getString(key: 'ad_entry_edit_2'),
582            'editExistingFaq' => false,
583            'faqId' => 0,
584            'faqLang' => $faqLanguage,
585            'faqRevisionId' => 0,
586            'faqData' => $faqData,
587            'openQuestionId' => 0,
588            'notifyUser' => $questionData['username'] ?? $this->currentUser->getUserData('display_name'),
589            'notifyEmail' => $questionData['email'] ?? $this->currentUser->getUserData('email'),
590            'categoryTree' => $category->getCategoryTree(),
591            'selectedCategories' => $categories,
592            'languageOptions' => LanguageHelper::renderSelectLanguage($faqLanguage, false, [], 'lang'),
593            'attachments' => [],
594            'allGroups' => true,
595            'restrictedGroups' => false,
596            'groupPermissionOptions' => $this->currentUser->perm instanceof MediumPermission
597                ? $this->currentUser->perm->getAllGroupsOptions([-1], $this->currentUser)
598                : '',
599            'allUsers' => true,
600            'restrictedUsers' => false,
601            'userSelection' => $this->userHelper->getAllUsersForTemplate(-1, true),
602            'changelogs' => [],
603            'hasPermissionForApprove' => $this->currentUser?->perm->hasPermission(
604                $this->currentUser->getUserId(),
605                PermissionType::FAQ_APPROVE->value,
606            ),
607            'isActive' => null,
608            'isInActive' => null,
609            'nextSolutionId' => $this->faq->getNextSolutionId(),
610            'nextFaqId' => $this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faqdata', 'id'),
611        ]);
612    }
613
614    /**
615     * @throws \Exception
616     * @return array<string, mixed>
617     */
618    private function getBaseTemplateVars(): array
619    {
620        $token = Token::getInstance($this->session);
621
622        $canAddAttachments = $this->currentUser?->perm->hasPermission(
623            $this->currentUser->getUserId(),
624            PermissionType::ATTACHMENT_ADD->value,
625        );
626
627        $canDeleteAttachments = $this->currentUser->perm->hasPermission(
628            $this->currentUser->getUserId(),
629            PermissionType::ATTACHMENT_DELETE->value,
630        );
631
632        $canTranslateFaqs = $this->currentUser->perm->hasPermission(
633            $this->currentUser->getUserId(),
634            PermissionType::FAQ_TRANSLATE->value,
635        );
636
637        return [
638            'csrfToken' => $token->getTokenString('pmf-csrf-token'),
639            'csrfTokenDeleteAttachment' => $token->getTokenString('delete-attachment'),
640            'csrfTokenUploadAttachment' => $token->getTokenString('upload-attachment'),
641            'isEditorEnabled' => $this->configuration->get(item: 'main.enableWysiwygEditor'),
642            'isMarkdownEditorEnabled' => $this->configuration->get(item: 'main.enableMarkdownEditor'),
643            'isBasicPermission' => $this->configuration->get(item: 'security.permLevel') === 'basic',
644            'defaultUrl' => $this->configuration->getDefaultUrl(),
645            'canBeNewRevision' => !$this->configuration->get(item: 'records.enableAutoRevisions'),
646            'maxAttachmentSize' => $this->configuration->get(item: 'records.maxAttachmentSize'),
647            'hasPermissionForAddAttachments' => $canAddAttachments,
648            'hasPermissionForDeleteAttachments' => $canDeleteAttachments,
649            'hasPermissionForTranslateFaqs' => $canTranslateFaqs,
650            'ad_entry_restricted_groups' => Translation::get(key: 'ad_entry_restricted_groups'),
651            'ad_entry_userpermission' => Translation::get(key: 'ad_entry_userpermission'),
652            'ad_entry_restricted_users' => Translation::get(key: 'ad_entry_restricted_users'),
653            'ad_entry_changelog' => Translation::get(key: 'ad_entry_changelog'),
654            'ad_entry_changed' => Translation::get(key: 'ad_entry_changed'),
655            'ad_admin_notes_hint' => Translation::get(key: 'ad_admin_notes_hint'),
656            'ad_admin_notes' => Translation::get(key: 'ad_admin_notes'),
657            'ad_entry_changelog_history' => Translation::get(key: 'ad_entry_changelog_history'),
658            'ad_gen_reset' => Translation::get(key: 'ad_gen_reset'),
659            'ad_entry_save' => Translation::get(key: 'ad_entry_save'),
660            'ad_entry_status' => Translation::get(key: 'ad_entry_status'),
661            'ad_entry_visibility' => Translation::get(key: 'ad_entry_visibility'),
662            'ad_entry_not_visibility' => Translation::get(key: 'ad_entry_not_visibility'),
663            'ad_entry_new_revision' => Translation::get(key: 'ad_entry_new_revision'),
664            'ad_gen_yes' => Translation::get(key: 'ad_gen_yes'),
665            'ad_gen_no' => Translation::get(key: 'ad_gen_no'),
666            'ad_entry_allowComments' => Translation::get(key: 'ad_entry_allowComments'),
667            'ad_entry_solution_id' => Translation::get(key: 'ad_entry_solution_id'),
668            'ad_att_addto' => Translation::get(key: 'ad_att_addto'),
669            'ad_att_addto_2' => Translation::get(key: 'ad_att_addto_2'),
670            'ad_att_att' => Translation::get(key: 'ad_att_att'),
671            'ad_att_butt' => Translation::get(key: 'ad_att_butt'),
672            'ad_changerev' => Translation::get(key: 'ad_changerev'),
673            'ad_view_faq' => Translation::get(key: 'ad_view_faq'),
674        ];
675    }
676}

Inherited from phpMyFAQ\Controller\Administration\AbstractAdministrationController

42    protected function initializeFromContainer(): void
43    {
44        parent::initializeFromContainer();
45
46        $adminLog = $this->container->get(id: 'phpmyfaq.admin.admin-log');
47        if (!$adminLog instanceof AdminLog) {
48            throw new \LogicException('AdminLog service not found in container.');
49        }
50
51        $this->adminLog = $adminLog;
52    }
64    public function render(string $file, array $context = [], ?Response $response = null): Response
65    {
66        $response = parent::render($file, $context, $response);
67        $response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
68        $response->headers->set('Pragma', 'no-cache');
69
70        return $response;
71    }
77    protected function getHeader(Request $request): array
78    {
79        $adminHelper = $this->container->get(id: 'phpmyfaq.admin.helper');
80        if (!$adminHelper instanceof AdminMenuBuilder) {
81            throw new \LogicException('AdminMenuBuilder service not found in container.');
82        }
83
84        $adminHelper->setUser($this->currentUser);
85
86        $secLevelEntries = $this->getSecondLevelEntries($adminHelper);
87        $pageFlags = $this->getPageFlags($request);
88        $gravatarImage = $this->getGravatarImage();
89
90        return [
91            'metaLanguage' => Translation::get(key: 'metaLanguage'),
92            'layoutMode' => 'light',
93            'defaultLayoutMode' => (string) ($this->configuration->get('layout.defaultLayoutMode') ?? 'auto'),
94            'allowUserLayoutMode' =>
95                $this->configuration->get('layout.allowUserLayoutMode') === true
96                    || $this->configuration->get('layout.allowUserLayoutMode') === 'true',
97            'pageTitle' => $this->configuration->getTitle() . ' - ' . System::getPoweredByPlainString(),
98            'baseHref' => $this->configuration->getDefaultUrl() . 'admin/',
99            'version' => System::getVersion(),
100            'currentYear' => date(format: 'Y'),
101            'metaRobots' => $this->configuration->get(item: 'seo.metaTagsAdmin'),
102            'templateSetName' => TwigWrapper::getTemplateSetName(),
103            'pageDirection' => Translation::get(key: 'direction'),
104            'userHasAccessPermission' => $adminHelper->canAccessContent($this->currentUser),
105            'msgSessionExpiration' => Translation::get(key: 'ad_session_expiration'),
106            'renderedLanguageSelection' => LanguageHelper::renderSelectLanguage(
107                $this->configuration->getLanguage()->getLanguage(),
108                true,
109                [],
110                'lang',
111            ),
112            'userName' => $this->currentUser->getUserData('display_name'),
113            'hasGravatarSupport' => $this->configuration->get(item: 'main.enableGravatarSupport'),
114            'gravatarImage' => $gravatarImage,
115            'msgChangePassword' => Translation::get(key: 'ad_menu_passwd'),
116            'csrfTokenLogout' => Token::getInstance($this->session)->getTokenString('admin-logout'),
117            'msgLogout' => Translation::get(key: 'admin_mainmenu_logout'),
118            'secondLevelEntries' => $secLevelEntries,
119            'menuUsers' => Translation::get(key: 'admin_mainmenu_users'),
120            'menuContent' => Translation::get(key: 'admin_mainmenu_content'),
121            'menuStatistics' => Translation::get(key: 'admin_mainmenu_statistics'),
122            'menuImportsExports' => Translation::get(key: 'admin_mainmenu_imports_exports'),
123            'menuBackup' => Translation::get(key: 'admin_mainmenu_backup'),
124            'menuConfiguration' => Translation::get(key: 'admin_mainmenu_configuration'),
125            'isSessionTimeoutCounterEnabled' => $this->configuration->get(
126                item: 'security.enableAdminSessionTimeoutCounter',
127            ),
128            'pluginStylesheets' => $this->configuration->getPluginManager()->getAllPluginStylesheets(),
129            'pluginScripts' => $this->configuration->getPluginManager()->getAllPluginScripts(),
130        ] + $pageFlags;
131    }
136    private function getSecondLevelEntries(AdminMenuBuilder $adminHelper): array
137    {
138        $secLevelEntries = [];
139
140        $secLevelEntries['user'] = $adminHelper->addMenuEntry(
141            'add_user+edit_user+delete_user',
142            'ad_menu_user_administration',
143            'user',
144        );
145        if ($this->configuration->get(item: 'security.permLevel') !== 'basic') {
146            $secLevelEntries['user'] .= $adminHelper->addMenuEntry(
147                'addgroup+editgroup+delgroup',
148                'ad_menu_group_administration',
149                'group',
150            );
151        }
152
153        $secLevelEntries['user'] .= $adminHelper->addMenuEntry(
154            PermissionType::PASSWORD_CHANGE->value,
155            'ad_menu_passwd',
156            'password/change',
157        );
158
159        $secLevelEntries['content'] = $adminHelper->addMenuEntry(
160            'addcateg+editcateg+delcateg',
161            'msgHeaderCategoryOverview',
162            'category',
163        );
164        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
165            PermissionType::FAQ_ADD->value,
166            'msgAddFAQ',
167            'faq/add',
168        );
169        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
170            'edit_faq+delete_faq',
171            'msgHeaderFAQOverview',
172            'faqs',
173        );
174        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
175            PermissionType::FAQ_EDIT->value,
176            'stickyRecordsHeader',
177            'sticky-faqs',
178        );
179        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
180            PermissionType::FAQ_EDIT->value,
181            'msgOrphanedFAQs',
182            'orphaned-faqs',
183        );
184        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
185            PermissionType::QUESTION_DELETE->value,
186            'ad_menu_open',
187            'questions',
188        );
189        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
190            PermissionType::COMMENT_DELETE->value,
191            'ad_menu_comments',
192            'comments',
193        );
194        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
195            'addattachment+editattachment+delattachment',
196            'msgAttachments',
197            'attachments',
198        );
199        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(PermissionType::FAQ_EDIT->value, 'msgTags', 'tags');
200        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
201            'addglossary+editglossary+delglossary',
202            'ad_menu_glossary',
203            'glossary',
204        );
205        $secLevelEntries['content'] .= $adminHelper->addMenuEntry('addnews+editnews+delnews', 'msgNews', 'news');
206        $secLevelEntries['content'] .= $adminHelper->addMenuEntry('addpage+editpage+delpage', 'ad_menu_pages', 'pages');
207
208        $secLevelEntries['statistics'] = $adminHelper->addMenuEntry(
209            PermissionType::STATISTICS_VIEWLOGS->value,
210            'ad_menu_stat',
211            'statistics/ratings',
212        );
213        $secLevelEntries['statistics'] .= $adminHelper->addMenuEntry(
214            PermissionType::STATISTICS_VIEWLOGS->value,
215            'ad_menu_session',
216            'statistics/sessions',
217        );
218        $secLevelEntries['statistics'] .= $adminHelper->addMenuEntry(
219            PermissionType::STATISTICS_ADMINLOG->value,
220            'ad_menu_adminlog',
221            'statistics/admin-log',
222        );
223        $secLevelEntries['statistics'] .= $adminHelper->addMenuEntry(
224            PermissionType::STATISTICS_VIEWLOGS->value,
225            'msgAdminElasticsearchStats',
226            'statistics/search',
227        );
228        $secLevelEntries['statistics'] .= $adminHelper->addMenuEntry(
229            PermissionType::REPORTS->value,
230            'ad_menu_reports',
231            'statistics/report',
232        );
233
234        $secLevelEntries['imports_exports'] = $adminHelper->addMenuEntry(
235            PermissionType::FAQ_ADD->value,
236            'msgImportRecords',
237            'import',
238        );
239        $secLevelEntries['imports_exports'] .= $adminHelper->addMenuEntry(
240            PermissionType::EXPORT->value,
241            'ad_menu_export',
242            'export',
243        );
244
245        $secLevelEntries['backup'] = $adminHelper->addMenuEntry(
246            PermissionType::CONFIGURATION_EDIT->value,
247            'ad_menu_backup',
248            'backup',
249        );
250
251        $secLevelEntries['config'] = $adminHelper->addMenuEntry(
252            PermissionType::CONFIGURATION_EDIT->value,
253            'ad_menu_editconfig',
254            'configuration',
255        );
256        $secLevelEntries['config'] .= $adminHelper->addMenuEntry('forms_edit', 'msgEditForms', 'forms');
257        $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
258            'editinstances+addinstances+delinstances',
259            'ad_menu_instances',
260            'instances',
261        );
262        $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
263            PermissionType::CONFIGURATION_EDIT->value,
264            'ad_menu_stopwordsconfig',
265            'stopwords',
266        );
267        if ($this->configuration->get(item: 'upgrade.onlineUpdateEnabled')) {
268            $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
269                PermissionType::CONFIGURATION_EDIT->value,
270                'msgAdminHeaderUpdate',
271                'update',
272            );
273        }
274
275        $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
276            PermissionType::CONFIGURATION_EDIT->value,
277            'msgPlugins',
278            'plugins',
279        );
280        if ($this->configuration->get(item: 'search.enableElasticsearch')) {
281            $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
282                PermissionType::CONFIGURATION_EDIT->value,
283                'msgAdminHeaderElasticsearch',
284                'elasticsearch',
285            );
286        }
287
288        if ($this->configuration->isLdapActive()) {
289            $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
290                PermissionType::CONFIGURATION_EDIT->value,
291                'msgAdminHeaderLdap',
292                'ldap',
293            );
294        }
295
296        if ($this->configuration->get(item: 'search.enableOpenSearch')) {
297            $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
298                PermissionType::CONFIGURATION_EDIT->value,
299                'msgAdminHeaderOpenSearch',
300                'opensearch',
301            );
302        }
303
304        $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
305            PermissionType::CONFIGURATION_EDIT->value,
306            'ad_system_info',
307            'system',
308        );
309
310        return $secLevelEntries;
311    }
316    private function getPageFlags(Request $request): array
317    {
318        $userPage = false;
319        $contentPage = false;
320        $statisticsPage = false;
321        $exportsPage = false;
322        $backupPage = false;
323        $configurationPage = false;
324
325        switch ($request->attributes->get('_route')) {
326            case 'admin.group':
327            case 'admin.group.add':
328            case 'admin.group.create':
329            case 'admin.password.change':
330            case 'admin.password.update':
331            case 'admin.user':
332            case 'admin.user.list':
333            case 'admin.user.edit':
334                $userPage = true;
335                break;
336            case 'admin.attachments':
337            case 'admin.category':
338            case 'admin.category.add':
339            case 'admin.category.add.child':
340            case 'admin.category.create':
341            case 'admin.category.edit':
342            case 'admin.category.hierarchy':
343            case 'admin.category.translate':
344            case 'admin.category.update':
345            case 'admin.content.orphaned-faqs':
346            case 'admin.content.sticky-faqs':
347            case 'admin.comments':
348            case 'admin.faq.add':
349            case 'admin.faq.answer':
350            case 'admin.faq.copy':
351            case 'admin.faq.edit':
352            case 'admin.faq.translate':
353            case 'admin.faqs':
354            case 'admin.glossary':
355            case 'admin.news':
356            case 'admin.news.add':
357            case 'admin.news.edit':
358            case 'admin.pages':
359            case 'admin.page.add':
360            case 'admin.page.edit':
361            case 'admin.page.translate':
362            case 'admin.questions':
363            case 'admin.tags':
364                $contentPage = true;
365                break;
366            case 'admin.statistics.admin-log':
367            case 'admin.statistics.ratings':
368            case 'admin.statistics.report':
369            case 'admin.statistics.sessions':
370            case 'admin.statistics.session.day':
371            case 'admin.statistics.session.id':
372            case 'admin.statistics.search':
373                $statisticsPage = true;
374                break;
375            case 'admin.export':
376            case 'admin.import':
377                $exportsPage = true;
378                break;
379            case 'admin.backup':
380            case 'admin.backup.export':
381            case 'admin.backup.restore':
382                $backupPage = true;
383                break;
384            case 'admin.configuration':
385            case 'admin.elasticsearch':
386            case 'admin.ldap':
387            case 'admin.opensearch':
388            case 'admin.forms':
389            case 'admin.instance.edit':
390            case 'admin.instance.update':
391            case 'admin.instances':
392            case 'admin.stopwords':
393            case 'admin.system':
394            case 'admin.configuration.plugins':
395            case 'admin.update':
396                $configurationPage = true;
397                break;
398        }
399
400        return [
401            'userPage' => $userPage,
402            'contentPage' => $contentPage,
403            'statisticsPage' => $statisticsPage,
404            'exportsPage' => $exportsPage,
405            'backupPage' => $backupPage,
406            'configurationPage' => $configurationPage,
407        ];
408    }
410    private function getGravatarImage(): string
411    {
412        if ($this->currentUser->isLoggedIn() && (bool) $this->configuration->get(item: 'main.enableGravatarSupport')) {
413            $email = $this->currentUser->getUserData('email');
414            $gravatar = new Gravatar();
415            return $gravatar->getImage(is_string($email) ? $email : '', [
416                'size' => '24',
417                'class' => 'img-profile rounded-circle',
418            ]);
419        }
420
421        return '';
422    }
428    protected function userHasPermission(PermissionType $permissionType): void
429    {
430        // Administration pages require authentication first: a logged-out user
431        // must be redirected to the login page (UnauthorizedHttpException),
432        // whereas ForbiddenException (403) is reserved for authenticated users
433        // who lack the required permission.
434        $this->userIsAuthenticated();
435        parent::userHasPermission($permissionType);
436    }
441    protected function getFooter(): array
442    {
443        return [
444            'msgModalSessionWarning' => sprintf(
445                Translation::getString('ad_session_expiring'),
446                PMF_AUTH_TIMEOUT_WARNING,
447            ),
448            'msgPoweredBy' => System::getPoweredByPlainString(),
449            'documentationUrl' => System::getDocumentationUrl(),
450            'phpMyFaqUrl' => System::PHPMYFAQ_URL,
451            'isUserLoggedIn' => $this->currentUser->isLoggedIn(),
452            'currentLanguage' => $this->configuration->getLanguage()->getLanguage(),
453            'currentYear' => date(format: 'Y'),
454        ];
455    }

Inherited from phpMyFAQ\Controller\AbstractController

93    public function setContainer(ContainerInterface $container): void
94    {
95        $this->container = $container;
96        $this->initializeFromContainer();
97    }
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    }
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    }