Lines 97.15% 444 / 457
Methods 73.33% 22 / 30
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% 14 / 14 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
32final class TagController extends AbstractAdministrationController
33{
34    public function __construct(
35        private readonly Tags $tags,
36    ) {
37        parent::__construct();
38    }
39
40    /**
41     * @throws LoaderError
42     * @throws Exception
43     * @throws \Exception
44     */
45    #[Route(path: '/tags', name: 'admin.tags', methods: ['GET'])]
46    public function index(Request $request): Response
47    {
48        $this->userHasPermission(PermissionType::FAQ_EDIT);
49
50        $this->tags->setBypassPermissionCheck();
51        $tagData = $this->tags->getAllTags();
52
53        return $this->render('@admin/content/tags.twig', [
54            ...$this->getHeader($request),
55            ...$this->getFooter(),
56            'adminHeaderTags' => Translation::get(key: 'msgTags'),
57            'csrfToken' => Token::getInstance($this->session)->getTokenInput('tags'),
58            'tags' => $tagData,
59            'noTags' => Translation::get(key: 'ad_news_nodata'),
60            'buttonEdit' => Translation::get(key: 'ad_user_edit'),
61            'msgConfirm' => Translation::get(key: 'ad_user_del_3'),
62            'buttonDelete' => Translation::get(key: 'msgDelete'),
63        ]);
64    }
65}

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    }