Lines 88.97% 347 / 390
Methods 67.44% 29 / 43
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 list 92.00% 23 / 25 0.00% 0 / 1 2.00
 uploadTheme 93.33% 14 / 15 0.00% 0 / 1 6.01
 save 82.85% 58 / 70 0.00% 0 / 1 30.67
 logSecurityConfigChanges 93.15% 68 / 73 0.00% 0 / 1 11.04
 translations 92.85% 13 / 14 0.00% 0 / 1 2.00
 templates 100.00% 8 / 8 100.00% 1 / 1 3
 faqsSortingKey 100.00% 2 / 2 100.00% 1 / 1 1
 faqsSortingOrder 100.00% 2 / 2 100.00% 1 / 1 1
 faqsSortingPopular 100.00% 4 / 4 100.00% 1 / 1 1
 permLevel 100.00% 2 / 2 100.00% 1 / 1 1
 releaseEnvironment 100.00% 4 / 4 100.00% 1 / 1 1
 searchRelevance 100.00% 4 / 4 100.00% 1 / 1 1
 seoMetaTags 100.00% 4 / 4 100.00% 1 / 1 1
 translationProvider 100.00% 4 / 4 100.00% 1 / 1 1
 mailProvider 100.00% 4 / 4 100.00% 1 / 1 1
 cacheAdapter 0.00% 0 / 4 0.00% 0 / 1 2
 layoutMode 100.00% 4 / 4 100.00% 1 / 1 1
 convertToString 33.33% 3 / 9 0.00% 0 / 1 12.41
 hasValidThemeCsrfToken 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Controller\Administration\Api\AbstractAdministrationApiController] initializeFromContainer 80.00% 4 / 5 0.00% 0 / 1 2.03
 [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
44final class ConfigurationTabController extends AbstractAdministrationApiController
45{
46    public function __construct(
47        private readonly Language $language,
48        private readonly System $faqSystem,
49        private readonly ThemeManager $themeManager,
50    ) {
51        parent::__construct();
52    }
53
54    /**
55     * @throws TemplateException
56     * @throws Exception
57     * @throws LoaderError
58     * @throws \Exception
59     */
60    #[Route(path: 'configuration/list/{mode}', name: 'admin.api.configuration.list', methods: ['GET'])]
61    public function list(Request $request): Response
62    {
63        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
64
65        $currentLanguage = $this->language->setLanguageByAcceptLanguage();
66
67        try {
68            Translation::create()
69                ->setTranslationsDir(PMF_LANGUAGE_DIR)
70                ->setDefaultLanguage(defaultLanguage: 'en')
71                ->setCurrentLanguage($currentLanguage)
72                ->setMultiByteLanguage();
73        } catch (Exception $exception) {
74            throw new BadRequestException($exception->getMessage());
75        }
76
77        $mode = (string) $request->attributes->get(key: 'mode');
78        $configurationList = Translation::getConfigurationItems($mode);
79
80        return $this->render(file: '@admin/configuration/tab-list.twig', context: [
81            'mode' => $mode,
82            'configurationList' => $configurationList,
83            'configurationData' => $this->configuration->getAll(),
84            'specialCases' => [
85                'ldapSupport' => extension_loaded(extension: 'ldap'),
86                'useSslForLogins' => Request::createFromGlobals()->isSecure(),
87                'useSslOnly' => Request::createFromGlobals()->isSecure(),
88                'ssoSupport' => Request::createFromGlobals()->server->get(key: 'REMOTE_USER'),
89                'buttonTes',
90            ],
91            'themeCsrfToken' => Token::getInstance($this->session)->getTokenString('theme-manager'),
92            'activeTheme' => (string) $this->configuration->get('layout.templateSet'),
93        ]);
94    }
95
96    /**
97     * @throws \Exception
98     */
99    #[Route(path: 'configuration/themes/upload', name: 'admin.api.configuration.themes.upload', methods: ['POST'])]
100    public function uploadTheme(Request $request): JsonResponse
101    {
102        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
103
104        if (!$this->hasValidThemeCsrfToken($request)) {
105            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
106        }
107
108        $file = $request->files->get('themeArchive');
109        if (!$file instanceof UploadedFile || !$file->isValid()) {
110            return $this->json(['error' => 'No valid ZIP file uploaded.'], Response::HTTP_BAD_REQUEST);
111        }
112
113        $themeName = trim((string) $request->request->get('themeName', ''));
114        if ($themeName === '') {
115            $themeName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
116        }
117
118        try {
119            $uploadedFiles = $this->themeManager->uploadTheme($themeName, $file->getPathname());
120
121            return $this->json([
122                'success' => sprintf('Theme "%s" uploaded (%d files).', $themeName, $uploadedFiles),
123            ], Response::HTTP_OK);
124        } catch (\RuntimeException $runtimeException) {
125            return $this->json(['error' => $runtimeException->getMessage()], Response::HTTP_BAD_REQUEST);
126        }
127    }
128
129    /**
130     * @throws \Exception
131     */
132    #[Route(path: 'configuration', name: 'admin.api.configuration.save', methods: ['POST'])]
133    public function save(Request $request): JsonResponse
134    {
135        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
136
137        $csrfToken = (string) $request->request->get(key: 'pmf-csrf-token');
138        $configurationData = $request->getPayload()->all(key: 'edit');
139        $availableFieldsJson = (string) $request->request->get(key: 'availableFields');
140
141        $oldConfigurationData = $this->configuration->getAll();
142
143        if (!Token::getInstance($this->session)->verifyToken(page: 'configuration', requestToken: $csrfToken)) {
144            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
145        }
146
147        // Parse the list of available fields from the form
148        $availableFields = [];
149        if ($availableFieldsJson !== '') {
150            $decodedFields = json_decode($availableFieldsJson, associative: true);
151            if (is_array($decodedFields)) {
152                $availableFields = array_map(
153                    static fn(mixed $fieldName): string => (string) $fieldName,
154                    $decodedFields,
155                );
156            }
157        }
158
159        // Set the new values
160        $newConfigValues = [];
161        $escapeValues = [
162            'main.contactInformation',
163            'main.customPdfHeader',
164            'main.customPdfFooter',
165            'main.titleFAQ',
166        ];
167
168        // Special checks
169        if (array_key_exists('main.enableMarkdownEditor', $configurationData)) {
170            $configurationData['main.enableWysiwygEditor'] = false; // Disable WYSIWYG editor if Markdown is enabled
171        }
172
173        if (array_key_exists('main.currentVersion', $configurationData)) {
174            unset($configurationData['main.currentVersion']); // don't update the version number
175        }
176
177        if (array_key_exists('records.attachmentsPath', $configurationData)) {
178            $realPath = realpath((string) $configurationData['records.attachmentsPath']);
179
180            if (false === $realPath) {
181                unset($configurationData['records.attachmentsPath']);
182            }
183
184            if (false !== $realPath) {
185                $configurationData['records.attachmentsPath'] = str_replace(
186                    search: (string) Request::createFromGlobals()->server->get(key: 'DOCUMENT_ROOT')
187                    . DIRECTORY_SEPARATOR,
188                    replace: '',
189                    subject: $realPath,
190                );
191            }
192        }
193
194        if (
195            array_key_exists('main.referenceURL', $configurationData)
196            && is_null(Filter::filterVar($configurationData['main.referenceURL'], FILTER_VALIDATE_URL))
197        ) {
198            unset($configurationData['main.referenceURL']);
199        }
200
201        foreach ($configurationData as $key => $value) {
202            $stringValue = is_scalar($value) || $value === null ? (string) $value : '';
203            $newConfigValues[(string) $key] = $stringValue;
204            // Escape some values
205            if (in_array($key, $escapeValues, strict: true)) {
206                $newConfigValues[(string) $key] = Strings::htmlspecialchars($stringValue, ENT_QUOTES);
207            }
208        }
209
210        // Only process fields that were available in the current form
211        // For checkboxes: if the field is available but not in configurationData, set to false
212        // For other fields: keep original value if not in configurationData
213        if ($availableFields !== []) {
214            foreach ($availableFields as $availableField) {
215                if (array_key_exists($availableField, $newConfigValues)) {
216                    continue;
217                }
218
219                if (
220                    array_key_exists($availableField, $oldConfigurationData)
221                    && $oldConfigurationData[$availableField] === 'true'
222                ) {
223                    $newConfigValues[$availableField] = 'false';
224                }
225            }
226        }
227
228        // Keep all values that were not in the available fields (from other tabs);
229        // runtime objects under core.* keys are never part of the form payload.
230        foreach ($oldConfigurationData as $key => $value) {
231            if (array_key_exists($key, $newConfigValues)) {
232                continue;
233            }
234
235            if (is_scalar($value) || $value === null) {
236                $newConfigValues[(string) $key] = $value === null ? null : (string) $value;
237            }
238        }
239
240        // Replace "main.referenceUrl" in FAQs
241        $oldReferenceUrl = (string) ($oldConfigurationData['main.referenceURL'] ?? '');
242        $newReferenceUrl = (string) ($newConfigValues['main.referenceURL'] ?? '');
243        if ($oldReferenceUrl !== $newReferenceUrl) {
244            $this->configuration->replaceMainReferenceUrl($oldReferenceUrl, $newReferenceUrl);
245        }
246
247        $this->configuration->update($newConfigValues);
248
249        // Filter out non-scalar values from old config before comparison
250        $oldConfigComparable = array_filter(
251            $oldConfigurationData,
252            static fn($value) => is_scalar($value) || $value === null,
253        );
254
255        $changedKeys = array_keys(array_diff_assoc($newConfigValues, $oldConfigComparable));
256
257        // General configuration change log
258        $this->adminLog->log($this->currentUser, AdminLogType::CONFIG_CHANGE->value . ':' . implode(',', $changedKeys));
259
260        // Specific security-related configuration change logs
261        $this->logSecurityConfigChanges($changedKeys, $oldConfigurationData, $newConfigValues);
262
263        return $this->json(['success' => Translation::get(key: 'ad_config_saved')], Response::HTTP_OK);
264    }
265
266    /**
267     * Log specific security-related configuration changes
268     */
269    /**
270     * @param list<string> $changedKeys
271     * @param array<array-key, mixed> $oldConfig
272     * @param array<array-key, mixed> $newConfig
273     */
274    private function logSecurityConfigChanges(array $changedKeys, array $oldConfig, array $newConfig): void
275    {
276        // Maintenance mode changes
277        if (in_array('main.maintenanceMode', $changedKeys, strict: true)) {
278            if ($newConfig['main.maintenanceMode'] === 'false' && $oldConfig['main.maintenanceMode'] === 'true') {
279                $this->adminLog->log($this->currentUser, AdminLogType::SYSTEM_MAINTENANCE_MODE_DISABLED->value);
280            }
281
282            if ($newConfig['main.maintenanceMode'] === 'true' && $oldConfig['main.maintenanceMode'] === 'false') {
283                $this->adminLog->log($this->currentUser, AdminLogType::SYSTEM_MAINTENANCE_MODE_ENABLED->value);
284            }
285        }
286
287        // Security configuration keys
288        $securityKeys = [
289            'security.permLevel',
290            'security.enableLoginOnly',
291            'security.enableRegistration',
292            'security.useSslForLogins',
293            'security.useSslOnly',
294            'security.forcePasswordUpdate',
295            'security.enableWebAuthnSupport',
296            'security.bannedIPs',
297            'security.loginWithEmailAddress',
298            'security.enableSignInWithMicrosoft',
299            'security.domainWhiteListForRegistrations',
300        ];
301
302        $securityChanges = array_intersect($changedKeys, $securityKeys);
303        if ($securityChanges !== []) {
304            $details = [];
305            foreach ($securityChanges as $key) {
306                $oldValue = $this->convertToString($oldConfig[$key] ?? null);
307                $newValue = $this->convertToString($newConfig[$key] ?? null);
308                $details[] = (string) $key . ':' . $oldValue . '->' . $newValue;
309            }
310            $this->adminLog->log(
311                $this->currentUser,
312                AdminLogType::CONFIG_SECURITY_CHANGED->value . ':' . implode(';', $details),
313            );
314        }
315
316        // LDAP configuration keys
317        $ldapKeys = [
318            'ldap.ldapSupport',
319            'ldap.ldap_server',
320            'ldap.ldap_port',
321            'ldap.ldap_base',
322            'ldap.ldap_groupSupport',
323        ];
324
325        $ldapChanges = array_intersect($changedKeys, $ldapKeys);
326        if ($ldapChanges !== []) {
327            $this->adminLog->log(
328                $this->currentUser,
329                AdminLogType::CONFIG_LDAP_CHANGED->value . ':' . implode(',', $ldapChanges),
330            );
331        }
332
333        // SSO configuration keys
334        $ssoKeys = [
335            'security.ssoSupport',
336            'security.ssoLogoutRedirect',
337            'keycloak.enable',
338            'keycloak.baseUrl',
339            'keycloak.realm',
340            'keycloak.clientId',
341            'keycloak.clientSecret',
342            'keycloak.redirectUri',
343            'keycloak.scopes',
344            'keycloak.autoProvision',
345            'keycloak.groupAutoAssign',
346            'keycloak.groupSyncOnLogin',
347            'keycloak.groupMapping',
348            'keycloak.logoutRedirectUrl',
349        ];
350
351        $ssoChanges = array_intersect($changedKeys, $ssoKeys);
352        if ($ssoChanges !== []) {
353            $this->adminLog->log(
354                $this->currentUser,
355                AdminLogType::CONFIG_SSO_CHANGED->value . ':' . implode(',', $ssoChanges),
356            );
357        }
358
359        // Encryption configuration keys
360        $encryptionKeys = [
361            'security.encryptionType',
362        ];
363
364        $encryptionChanges = array_intersect($changedKeys, $encryptionKeys);
365        if ($encryptionChanges !== []) {
366            $this->adminLog->log(
367                $this->currentUser,
368                AdminLogType::CONFIG_ENCRYPTION_CHANGED->value . ':' . implode(',', $encryptionChanges),
369            );
370        }
371    }
372
373    /**
374     * @throws \Exception
375     */
376    #[Route(path: 'configuration/translations', name: 'admin.api.configuration.translations', methods: ['GET'])]
377    public function translations(): Response
378    {
379        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
380
381        $response = new Response();
382
383        $languages = LanguageHelper::getAvailableLanguages();
384        if ($languages !== []) {
385            return $response->setContent(LanguageHelper::renderLanguageOptions(
386                str_replace(
387                    ['language_', '.php'],
388                    replace: '',
389                    subject: (string) $this->configuration->get(item: 'main.language'),
390                ),
391                onlyThisLang: false,
392                fileLanguageValue: true,
393            ));
394        }
395
396        return $response->setContent(content: '<option value="language_en.php">English</option>');
397    }
398
399    /**
400     * @throws \Exception
401     */
402    #[Route(path: 'configuration/templates', name: 'admin.api.configuration.templates', methods: ['GET'])]
403    public function templates(): Response
404    {
405        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
406
407        $response = new Response();
408        $templates = $this->faqSystem->getAvailableTemplates();
409        $htmlString = '';
410
411        foreach ($templates as $template => $selected) {
412            $selectedAttribute = $selected === true ? ' selected' : '';
413            $htmlString .= sprintf('<option%s>%s</option>', $selectedAttribute, $template);
414        }
415
416        return $response->setContent($htmlString);
417    }
418
419    #[Route(
420        path: 'configuration/faqs-sorting-key/{current}',
421        name: 'admin.api.configuration.faqs-sorting-key',
422        methods: ['GET'],
423    )]
424    public function faqsSortingKey(Request $request): Response
425    {
426        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
427
428        return new Response(AdminMenuBuilder::sortingKeyOptions((string) $request->attributes->get(key: 'current')));
429    }
430
431    #[Route(
432        path: 'configuration/faqs-sorting-order/{current}',
433        name: 'admin.api.configuration.faqs-sorting-order',
434        methods: ['GET'],
435    )]
436    public function faqsSortingOrder(Request $request): Response
437    {
438        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
439
440        return new Response(AdminMenuBuilder::sortingOrderOptions((string) $request->attributes->get(key: 'current')));
441    }
442
443    #[Route(
444        path: 'configuration/faqs-sorting-popular/{current}',
445        name: 'admin.api.configuration.faqs-sorting-popular',
446        methods: ['GET'],
447    )]
448    public function faqsSortingPopular(Request $request): Response
449    {
450        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
451
452        return new Response(AdminMenuBuilder::sortingPopularFaqsOptions((string) $request->attributes->get(
453            key: 'current',
454        )));
455    }
456
457    #[Route(path: 'configuration/perm-level/{current}', name: 'admin.api.configuration.permLevel', methods: ['GET'])]
458    public function permLevel(Request $request): Response
459    {
460        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
461
462        return new Response(PermissionHelper::permOptions((string) $request->attributes->get(key: 'current')));
463    }
464
465    #[Route(
466        path: 'configuration/release-environment/{current}',
467        name: 'admin.api.configuration.release-environment',
468        methods: ['GET'],
469    )]
470    public function releaseEnvironment(Request $request): Response
471    {
472        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
473
474        return new Response(AdminMenuBuilder::renderReleaseTypeOptions((string) $request->attributes->get(
475            key: 'current',
476        )));
477    }
478
479    #[Route(
480        path: 'configuration/search-relevance/{current}',
481        name: 'admin.api.configuration.search-relevance',
482        methods: ['GET'],
483    )]
484    public function searchRelevance(Request $request): Response
485    {
486        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
487
488        return new Response(AdminMenuBuilder::searchRelevanceOptions((string) $request->attributes->get(
489            key: 'current',
490        )));
491    }
492
493    #[Route(
494        path: 'configuration/seo-metatags/{current}',
495        name: 'admin.api.configuration.seo-metatags',
496        methods: ['GET'],
497    )]
498    public function seoMetaTags(Request $request): Response
499    {
500        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
501
502        return new Response(AdminMenuBuilder::renderMetaRobotsDropdown((string) $request->attributes->get(
503            key: 'current',
504        )));
505    }
506
507    #[Route(
508        path: 'configuration/translation-provider/{current}',
509        name: 'admin.api.configuration.translation-provider',
510        methods: ['GET'],
511    )]
512    public function translationProvider(Request $request): Response
513    {
514        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
515
516        return new Response(AdminMenuBuilder::renderTranslationProviderOptions((string) $request->attributes->get(
517            key: 'current',
518        )));
519    }
520
521    #[Route(
522        path: 'configuration/mail-provider/{current}',
523        name: 'admin.api.configuration.mail-provider',
524        methods: ['GET'],
525    )]
526    public function mailProvider(Request $request): Response
527    {
528        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
529
530        return new Response(AdminMenuBuilder::renderMailProviderOptions((string) $request->attributes->get(
531            key: 'current',
532        )));
533    }
534
535    #[Route(
536        path: 'configuration/cache-adapter/{current}',
537        name: 'admin.api.configuration.cache-adapter',
538        methods: ['GET'],
539    )]
540    public function cacheAdapter(Request $request): Response
541    {
542        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
543
544        return new Response(AdminMenuBuilder::renderCacheAdapterOptions((string) $request->attributes->get(
545            key: 'current',
546        )));
547    }
548
549    #[Route(path: 'configuration/layout-mode/{current}', name: 'admin.api.configuration.layout-mode', methods: ['GET'])]
550    public function layoutMode(Request $request): Response
551    {
552        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
553
554        return new Response(AdminMenuBuilder::renderLayoutModeOptions((string) $request->attributes->get(
555            key: 'current',
556        )));
557    }
558
559    /**
560     * Converts a value to string safely, handling objects and null values
561     */
562    private function convertToString(mixed $value): string
563    {
564        if ($value === null) {
565            return 'null';
566        }
567
568        if (is_scalar($value)) {
569            return (string) $value;
570        }
571
572        if (is_object($value)) {
573            return get_class($value);
574        }
575
576        if (is_array($value)) {
577            return 'array';
578        }
579
580        return 'unknown';
581    }
582
583    private function hasValidThemeCsrfToken(Request $request): bool
584    {
585        $csrfToken = (string) $request->request->get('theme-csrf-token', '');
586        return Token::getInstance($this->session)->verifyToken('theme-manager', $csrfToken);
587    }
588}

Inherited from phpMyFAQ\Controller\Administration\Api\AbstractAdministrationApiController

31    protected function initializeFromContainer(): void
32    {
33        parent::initializeFromContainer();
34
35        $adminLog = $this->container->get(id: 'phpmyfaq.admin.admin-log');
36        if (!$adminLog instanceof AdminLog) {
37            throw new \LogicException('AdminLog service not found in container.');
38        }
39
40        $this->adminLog = $adminLog;
41    }

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    }