Lines 70.52% 201 / 285
Methods 63.15% 24 / 38
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 getFreshCache 81.81% 9 / 11 0.00% 0 / 1 7.29
 getStaleCache 100.00% 4 / 4 100.00% 1 / 1 4
 storeCache 0.00% 0 / 4 0.00% 0 / 1 2
 verify 100.00% 4 / 4 100.00% 1 / 1 1
 versions 60.00% 15 / 25 0.00% 0 / 1 12.10
 visits 100.00% 8 / 8 100.00% 1 / 1 3
 topTen 100.00% 5 / 5 100.00% 1 / 1 2
 news 11.11% 3 / 27 0.00% 0 / 1 80.23
 searches 100.00% 3 / 3 100.00% 1 / 1 1
 contentHealth 100.00% 3 / 3 100.00% 1 / 1 1
 getLayout 100.00% 3 / 3 100.00% 1 / 1 1
 saveLayout 53.84% 7 / 13 0.00% 0 / 1 7.46
 resetLayout 54.54% 6 / 11 0.00% 0 / 1 7.35
 sanitizeLayout 0.00% 0 / 19 0.00% 0 / 1 72
 [phpMyFAQ\Controller\AbstractController] setContainer 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] initializeFromContainer 78.57% 11 / 14 0.00% 0 / 1 4.16
 [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
45final class DashboardController extends AbstractController
46{
47    /**
48     * How long a cached remote response is considered fresh, in seconds.
49     */
50    private const REMOTE_CACHE_TTL = 3600;
51
52    /**
53     * How long a cached remote response is retained for stale-on-error fallback, in seconds.
54     */
55    private const REMOTE_CACHE_RETENTION = 86_400;
56
57    /**
58     * Widget keys that may appear in a stored dashboard layout. Anything else is dropped.
59     */
60    private const ALLOWED_WIDGETS = [
61        'inactive-faqs',
62        'recent-users',
63        'content-health',
64        'popular-searches',
65        'version-check',
66        'verification-check',
67        'backup-status',
68        'sponsor',
69        'support',
70    ];
71
72    public function __construct(
73        private readonly AdminSession $adminSession,
74        private readonly CacheItemPoolInterface $cache,
75    ) {
76        parent::__construct();
77    }
78
79    /**
80     * Returns a still-fresh cached payload for the given key, or null when none exists.
81     *
82     * @return array<string, mixed>|null
83     * @throws InvalidArgumentException
84     */
85    private function getFreshCache(string $key): ?array
86    {
87        $item = $this->cache->getItem($key);
88        if (!$item->isHit()) {
89            return null;
90        }
91
92        $cached = $item->get();
93        if (!is_array($cached) || !array_key_exists('fetchedAt', $cached) || !array_key_exists('payload', $cached)) {
94            return null;
95        }
96
97        if ((time() - (int) $cached['fetchedAt']) > self::REMOTE_CACHE_TTL) {
98            return null;
99        }
100
101        if (!is_array($cached['payload'])) {
102            return null;
103        }
104
105        /* @mago-expect analysis:less-specific-return-statement - the cache payload keys are re-validated by consumers */
106        return $cached['payload'];
107    }
108
109    /**
110     * Returns any cached payload for the given key regardless of age (stale-on-error fallback).
111     *
112     * @return array<string, mixed>|null
113     * @throws InvalidArgumentException
114     */
115    private function getStaleCache(string $key): ?array
116    {
117        $cached = $this->cache->getItem($key)->get();
118        if (is_array($cached) && array_key_exists('payload', $cached) && is_array($cached['payload'])) {
119            /* @mago-expect analysis:less-specific-return-statement - the cache payload keys are re-validated by consumers */
120            return $cached['payload'];
121        }
122
123        return null;
124    }
125
126    /**
127     * Stores a remote payload together with its fetch timestamp.
128     *
129     * @param array<string, mixed> $payload
130     * @throws InvalidArgumentException
131     */
132    private function storeCache(string $key, array $payload): void
133    {
134        $item = $this->cache->getItem($key);
135        $item->set(['fetchedAt' => time(), 'payload' => $payload]);
136        $item->expiresAfter(self::REMOTE_CACHE_RETENTION);
137        $this->cache->save($item);
138    }
139
140    /**
141     * @throws JsonException
142     */
143    #[Route(path: 'dashboard/verify', name: 'admin.api.dashboard.verify', methods: ['POST'])]
144    public function verify(Request $request): JsonResponse
145    {
146        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
147
148        $data = $request->getContent();
149        $api = new RemoteApiClient($this->configuration, new System());
150
151        return $this->json($api->setRemoteHashes($data)->getVerificationIssues());
152    }
153
154    /**
155     * @throws Exception
156     */
157    #[Route(path: 'dashboard/versions', name: 'admin.api.dashboard.versions', methods: ['GET'])]
158    public function versions(): JsonResponse
159    {
160        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
161
162        $releaseEnvironment = (string) $this->configuration->get(item: 'upgrade.releaseEnvironment');
163        $cacheKey = 'dashboard.versions.' . $releaseEnvironment;
164
165        $fresh = $this->getFreshCache($cacheKey);
166        if ($fresh !== null) {
167            return $this->json($fresh);
168        }
169
170        $api = new RemoteApiClient($this->configuration, new System());
171
172        try {
173            $versions = $api->getVersions();
174            if (!array_key_exists('installed', $versions) || !array_key_exists($releaseEnvironment, $versions)) {
175                throw new Exception('Version lookup failed for release environment "' . $releaseEnvironment . '".');
176            }
177
178            $info = [];
179            if (version_compare($versions['installed'], $versions[$releaseEnvironment]) < 0) {
180                $info = ['warning' => Translation::get(key: 'ad_you_should_update')];
181            }
182
183            if (version_compare($versions['installed'], $versions[$releaseEnvironment]) >= 0) {
184                $info = [
185                    'success' =>
186                        Translation::getString(key: 'ad_xmlrpc_latest') . ': phpMyFAQ ' . ($versions['stable'] ?? ''),
187                ];
188            }
189
190            $this->storeCache($cacheKey, $info);
191
192            return $this->json($info);
193        } catch (DecodingExceptionInterface|TransportExceptionInterface|Exception $exception) {
194            $stale = $this->getStaleCache($cacheKey);
195            if ($stale !== null) {
196                return $this->json($stale);
197            }
198
199            return $this->json(['error' => $exception->getMessage()], Response::HTTP_BAD_REQUEST);
200        }
201    }
202
203    /**
204     * @throws Exception
205     */
206    #[Route(path: 'dashboard/visits', name: 'admin.api.dashboard.visits', methods: ['GET'])]
207    public function visits(Request $request): JsonResponse
208    {
209        $this->userHasPermission(PermissionType::STATISTICS_VIEWLOGS);
210
211        if ($this->configuration->get(item: 'main.enableUserTracking')) {
212            $requestTime = (int) $request->server->get('REQUEST_TIME');
213            $endDate = $requestTime !== 0 ? $requestTime : time();
214            $days = (int) $request->query->get('days', 30);
215            $days = max(7, min($days, 365));
216            return $this->json($this->adminSession->getVisitsForDays($endDate, $days));
217        }
218
219        return $this->json(['error' => 'User tracking is disabled.'], 400);
220    }
221
222    /**
223     * @throws Exception
224     */
225    #[Route(path: 'dashboard/topten', name: 'admin.api.dashboard.topten', methods: ['GET'])]
226    public function topTen(): JsonResponse
227    {
228        $this->userHasPermission(PermissionType::STATISTICS_VIEWLOGS);
229
230        if ($this->configuration->get(item: 'main.enableUserTracking')) {
231            $faqStatistics = new Faq\Statistics($this->configuration);
232            return $this->json($faqStatistics->getTopTenData());
233        }
234
235        return $this->json(['error' => 'User tracking is disabled.'], 400);
236    }
237
238    /**
239     * @throws Exception
240     */
241    #[Route(path: 'dashboard/news', name: 'admin.api.dashboard.news', methods: ['GET'])]
242    public function news(): JsonResponse
243    {
244        $this->userIsAuthenticated();
245
246        if (!$this->configuration->get(item: 'main.enableRecentNews')) {
247            return $this->json(['error' => 'Recent news is disabled.'], Response::HTTP_FORBIDDEN);
248        }
249
250        $cacheKey = 'dashboard.news';
251
252        $fresh = $this->getFreshCache($cacheKey);
253        if ($fresh !== null) {
254            return $this->json($fresh);
255        }
256
257        try {
258            $httpClient = HttpClient::create(['max_redirects' => 2, 'timeout' => 10]);
259            $response = $httpClient->request('GET', 'https://www.phpmyfaq.de/api/news/recent');
260
261            if ($response->getStatusCode() === Response::HTTP_OK) {
262                $data = $response->toArray(throw: false);
263                if (array_key_exists('news', $data) && is_array($data['news'])) {
264                    $data['news'] = array_slice($data['news'], offset: 0, length: 5);
265                }
266
267                $payload = [];
268                foreach ($data as $payloadKey => $payloadValue) {
269                    $payload[(string) $payloadKey] = $payloadValue;
270                }
271
272                $this->storeCache($cacheKey, $payload);
273
274                return $this->json($data);
275            }
276
277            $stale = $this->getStaleCache($cacheKey);
278            if ($stale !== null) {
279                return $this->json($stale);
280            }
281
282            return $this->json(['error' => 'Failed to fetch news.'], Response::HTTP_BAD_GATEWAY);
283        } catch (TransportExceptionInterface $exception) {
284            $stale = $this->getStaleCache($cacheKey);
285            if ($stale !== null) {
286                return $this->json($stale);
287            }
288
289            return $this->json(['error' => $exception->getMessage()], Response::HTTP_BAD_GATEWAY);
290        }
291    }
292
293    /**
294     * Returns the most popular search terms of the last 30 days.
295     *
296     * @throws Exception
297     */
298    #[Route(path: 'dashboard/searches', name: 'admin.api.dashboard.searches', methods: ['GET'])]
299    public function searches(): JsonResponse
300    {
301        $this->userIsAuthenticated();
302
303        $search = new Search($this->configuration);
304
305        return $this->json($search->getMostPopularSearches(numResults: 7, withLang: false, timeWindow: 30));
306    }
307
308    /**
309     * Returns aggregated content health counters (orphaned and stale FAQs).
310     *
311     * @throws Exception
312     */
313    #[Route(path: 'dashboard/content-health', name: 'admin.api.dashboard.content-health', methods: ['GET'])]
314    public function contentHealth(): JsonResponse
315    {
316        $this->userIsAuthenticated();
317
318        $faq = new AdminFaq($this->configuration);
319
320        return $this->json($faq->getContentHealthStatistics());
321    }
322
323    /**
324     * Returns the stored dashboard widget layout of the current admin user.
325     *
326     * @throws Exception
327     */
328    #[Route(path: 'dashboard/layout', name: 'admin.api.dashboard.layout.get', methods: ['GET'])]
329    public function getLayout(): JsonResponse
330    {
331        $this->userIsAuthenticated();
332
333        $dashboardLayout = new DashboardLayout($this->configuration);
334
335        return $this->json(['config' => $dashboardLayout->get($this->currentUser->getUserId())]);
336    }
337
338    /**
339     * Persists the dashboard widget layout of the current admin user.
340     *
341     * @throws Exception
342     */
343    #[Route(path: 'dashboard/layout', name: 'admin.api.dashboard.layout.save', methods: ['POST'])]
344    public function saveLayout(Request $request): JsonResponse
345    {
346        $this->userIsAuthenticated();
347
348        $data = json_decode($request->getContent());
349        if (!is_object($data)) {
350            return $this->json(['error' => 'Invalid request body.'], Response::HTTP_BAD_REQUEST);
351        }
352
353        $csrfToken = is_string($data->csrfToken ?? null) ? $data->csrfToken : null;
354        if (!Token::getInstance($this->session)->verifyToken('dashboard', $csrfToken)) {
355            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
356        }
357
358        $dashboardLayout = new DashboardLayout($this->configuration);
359        $config = $this->sanitizeLayout($data->config ?? null);
360        $saved = $dashboardLayout->save($this->currentUser->getUserId(), $config);
361
362        if (!$saved) {
363            return $this->json(['error' => 'Could not save layout.'], Response::HTTP_INTERNAL_SERVER_ERROR);
364        }
365
366        return $this->json(['success' => true, 'config' => $config]);
367    }
368
369    /**
370     * Removes the stored layout of the current admin user, reverting to the default.
371     *
372     * @throws Exception
373     */
374    #[Route(path: 'dashboard/layout/reset', name: 'admin.api.dashboard.layout.reset', methods: ['POST'])]
375    public function resetLayout(Request $request): JsonResponse
376    {
377        $this->userIsAuthenticated();
378
379        $data = json_decode($request->getContent());
380        if (!is_object($data)) {
381            return $this->json(['error' => 'Invalid request body.'], Response::HTTP_BAD_REQUEST);
382        }
383
384        $csrfToken = is_string($data->csrfToken ?? null) ? $data->csrfToken : null;
385        if (!Token::getInstance($this->session)->verifyToken('dashboard', $csrfToken)) {
386            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
387        }
388
389        $dashboardLayout = new DashboardLayout($this->configuration);
390        if (!$dashboardLayout->reset($this->currentUser->getUserId())) {
391            return $this->json(['error' => 'Could not reset layout.'], Response::HTTP_INTERNAL_SERVER_ERROR);
392        }
393
394        return $this->json(['success' => true]);
395    }
396
397    /**
398     * Validates an untrusted layout payload, keeping only known widgets and a clean shape.
399     *
400     * @return array<int, array{key: string, position: int, visible: bool}>
401     */
402    private function sanitizeLayout(mixed $config): array
403    {
404        if (!is_array($config)) {
405            return [];
406        }
407
408        $clean = [];
409        $seen = [];
410        $position = 0;
411
412        foreach ($config as $entry) {
413            $key = is_object($entry) ? $entry->key ?? null : null;
414            if (
415                !is_string($key)
416                || !in_array($key, self::ALLOWED_WIDGETS, strict: true)
417                || array_key_exists($key, $seen)
418            ) {
419                continue;
420            }
421
422            $seen[$key] = true;
423            $visible = is_object($entry) ? $entry->visible ?? true : true;
424            $clean[] = [
425                'key' => $key,
426                'position' => $position++,
427                'visible' => (bool) $visible,
428            ];
429        }
430
431        return $clean;
432    }
433}

Inherited from phpMyFAQ\Controller\AbstractController

93    public function setContainer(ContainerInterface $container): void
94    {
95        $this->container = $container;
96        $this->initializeFromContainer();
97    }
104    protected function initializeFromContainer(): void
105    {
106        $configuration = $this->container->get(id: 'phpmyfaq.configuration');
107        if (!$configuration instanceof Configuration) {
108            throw new LogicException('Configuration service not found in container.');
109        }
110
111        $this->configuration = $configuration;
112
113        $currentUser = $this->container->get(id: 'phpmyfaq.user.current_user');
114        if (!$currentUser instanceof CurrentUser) {
115            throw new LogicException('CurrentUser service not found in container.');
116        }
117
118        $this->currentUser = $currentUser;
119
120        $session = $this->container->get(id: 'session');
121        if (!$session instanceof FlashBagAwareSessionInterface) {
122            throw new LogicException('Session service not found in container.');
123        }
124
125        $this->session = $session;
126
127        TwigWrapper::setTemplateSetName($this->configuration->getTemplateSet());
128        $this->isSecured();
129    }
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    }