Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.41% covered (success)
90.41%
132 / 146
70.83% covered (warning)
70.83%
17 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractController
90.41% covered (success)
90.41%
132 / 146
70.83% covered (warning)
70.83%
17 / 24
64.28
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setContainer
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 initializeFromContainer
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
4.16
 render
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 renderView
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 json
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getJsonObject
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getTwigWrapper
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 hasValidToken
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 isSecured
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 isPublicAuthenticationPath
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 userIsAuthenticated
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 userIsSuperAdmin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 userHasGroupPermission
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 userHasUserPermission
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 userHasPermission
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 userHasAnyPermission
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 verifySessionCsrfToken
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
4.43
 captchaCodeIsValid
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 isApiEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addExtension
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRateLimiter
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 createFallbackContainer
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
2.09
1<?php
2
3/**
4 * Abstract Controller for phpMyFAQ
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2023-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2023-10-24
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller;
21
22use JsonException;
23use LogicException;
24use OpenApi\Attributes as OA;
25use phpMyFAQ\Captcha\Captcha;
26use phpMyFAQ\Configuration;
27use phpMyFAQ\Container\ContainerRegistry;
28use phpMyFAQ\Controller\Exception\ForbiddenException;
29use phpMyFAQ\Core\Exception;
30use phpMyFAQ\Enums\PermissionType;
31use phpMyFAQ\Filter;
32use phpMyFAQ\Form\FormsServiceProvider;
33use phpMyFAQ\Http\RateLimiter;
34use phpMyFAQ\Session\Token;
35use phpMyFAQ\Twig\TwigWrapper;
36use phpMyFAQ\User\CurrentUser;
37use Symfony\Component\Config\FileLocator;
38use Symfony\Component\DependencyInjection\ContainerBuilder;
39use Symfony\Component\DependencyInjection\ContainerInterface;
40use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
41use Symfony\Component\HttpFoundation\JsonResponse;
42use Symfony\Component\HttpFoundation\Request;
43use Symfony\Component\HttpFoundation\Response;
44use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
45use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
46use Twig\Error\LoaderError;
47use Twig\Extension\ExtensionInterface;
48use Twig\TwigFilter;
49
50#[OA\Info(
51    version: '4.0',
52    description: 'phpMyFAQ includes a REST API and offers APIs for various services like fetching the phpMyFAQ '
53    . 'version or doing a search against the phpMyFAQ installation.',
54    title: 'REST API for phpMyFAQ 4.2',
55    contact: new OA\Contact(name: 'phpMyFAQ Team', email: 'support@phpmyfaq.de'),
56)]
57#[OA\Server(url: 'https://localhost', description: 'Local dockerized server')]
58#[OA\License(name: 'Mozilla Public Licence 2.0', url: 'https://www.mozilla.org/MPL/2.0/')]
59abstract class AbstractController
60{
61    protected ContainerInterface $container;
62
63    protected Configuration $configuration;
64
65    protected CurrentUser $currentUser;
66
67    protected FlashBagAwareSessionInterface $session;
68
69    /** @var ExtensionInterface[] */
70    private array $twigExtensions = [];
71
72    /** @var TwigFilter[] */
73    private array $twigFilters = [];
74
75    /**
76     * Reuses the Kernel's shared DI container when one is registered; only when no
77     * Kernel booted (CLI scripts, tests) a private fallback container is built.
78     * Parsing services.php into a fallback per controller instantiation used to
79     * happen unconditionally, even though the Kernel overwrote it right away.
80     *
81     * @throws \Exception
82     */
83    public function __construct()
84    {
85        $this->container = ContainerRegistry::get() ?? $this->createFallbackContainer();
86        $this->initializeFromContainer();
87    }
88
89    /**
90     * Sets the shared DI container from the Kernel.
91     * Called by ControllerContainerListener on "kernel.controller" event.
92     */
93    public function setContainer(ContainerInterface $container): void
94    {
95        $this->container = $container;
96        $this->initializeFromContainer();
97    }
98
99    /**
100     * Initializes configuration, user, and session from the container.
101     *
102     * @throws \Exception
103     */
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    }
130
131    /**
132     * Returns a Twig-rendered template as a response.
133     *
134     * @param array<array-key, mixed> $context
135     * @throws Exception|LoaderError
136     */
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    }
147
148    /**
149     * Returns a Twig-rendered template as a string.
150     *
151     * @param array<string, array<int<0, max>, array<string, mixed>>> $templateVars
152     * @throws Exception|LoaderError
153     */
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    }
161
162    /**
163     * Returns a JsonResponse that uses json_encode().
164     *
165     * @param string[] $headers
166     */
167    public function json(mixed $data, int $status = 200, array $headers = []): JsonResponse
168    {
169        return new JsonResponse($data, $status, $headers);
170    }
171
172    /**
173     * Decodes the JSON request body into an object.
174     *
175     * Guarantees the body is a JSON object (not a scalar, array, or malformed
176     * input), so callers can read properties without `mixed` leaking out of
177     * `json_decode()`. Leaf property values are still `mixed` and must be
178     * validated or cast at the point of use.
179     *
180     * @throws JsonException if the body is missing or is not a JSON object
181     */
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    }
193
194    /**
195     * @throws LoaderError
196     */
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    }
215
216    /**
217     * @throws UnauthorizedHttpException|\Exception
218     */
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    }
232
233    /**
234     * @throws \Exception
235     */
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    }
256
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    }
284
285    /**
286     * @throws UnauthorizedHttpException
287     */
288    public function userIsAuthenticated(): void
289    {
290        if (!$this->currentUser->isLoggedIn()) {
291            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
292        }
293    }
294
295    /**
296     * @throws UnauthorizedHttpException
297     */
298    protected function userIsSuperAdmin(): void
299    {
300        if (!$this->currentUser->isSuperAdmin()) {
301            throw new UnauthorizedHttpException(challenge: 'User is not super admin.');
302        }
303    }
304
305    /**
306     * @throws UnauthorizedHttpException|ForbiddenException
307     */
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    }
324
325    /**
326     * @throws UnauthorizedHttpException|ForbiddenException
327     */
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    }
343
344    /**
345     * @throws UnauthorizedHttpException|ForbiddenException
346     */
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    }
358
359    /**
360     * Grants access when the user owns at least one of the given permissions.
361     *
362     * @throws UnauthorizedHttpException|ForbiddenException
363     */
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    }
382
383    /**
384     * Verifies CSRF token using session only (without cookie verification).
385     * Use this for authenticated API endpoints where the full cookie-based CSRF
386     * verification may fail due to domain/path mismatches.
387     * @throws \Exception
388     */
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    }
409
410    /**
411     * @throws JsonException
412     * @throws \Exception
413     */
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    }
428
429    public function isApiEnabled(): bool
430    {
431        return (bool) $this->configuration->get(item: 'api.enableAccess');
432    }
433
434    public function addExtension(ExtensionInterface $extension): void
435    {
436        $this->twigExtensions[] = $extension;
437    }
438
439    public function addFilter(TwigFilter $twigFilter): void
440    {
441        $this->twigFilters[] = $twigFilter;
442    }
443
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    }
454
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    }
470}