Lines 73.19% 213 / 291
Methods 58.82% 20 / 34
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 healthCheck 72.22% 13 / 18 0.00% 0 / 1 3.19
 versions 20.00% 1 / 5 0.00% 0 / 1 4.05
 updateCheck 92.59% 25 / 27 0.00% 0 / 1 5.01
 downloadPackage 94.73% 18 / 19 0.00% 0 / 1 5.00
 extractPackage 4.76% 1 / 21 0.00% 0 / 1 17.82
 createTemporaryBackup 5.88% 1 / 17 0.00% 0 / 1 17.34
 installPackage 5.88% 1 / 17 0.00% 0 / 1 25.84
 updateDatabase 100.00% 14 / 14 100.00% 1 / 1 4
 cleanUp 100.00% 5 / 5 100.00% 1 / 1 2
 isValidUpdatePackageToken 100.00% 3 / 3 100.00% 1 / 1 2
 [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
46final class UpdateController extends AbstractController
47{
48    public function __construct(
49        private readonly Upgrade $upgrade,
50        private readonly RemoteApiClient $adminApi,
51        private readonly Update $update,
52        private readonly EnvironmentConfigurator $configurator,
53    ) {
54        parent::__construct();
55    }
56
57    /**
58     * @throws Exception|\Exception
59     */
60    #[Route(path: 'health-check', name: 'admin.api.health-check', methods: ['GET'])]
61    public function healthCheck(): JsonResponse
62    {
63        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
64
65        $dateTime = new DateTime();
66        $dateLastChecked = $dateTime->format(DateTimeInterface::ATOM);
67
68        if (!$this->upgrade->isMaintenanceEnabled()) {
69            return $this->json([
70                'warning' => Translation::get(key: 'msgNotInMaintenanceMode'),
71                'dateLastChecked' => $dateLastChecked,
72            ], Response::HTTP_CONFLICT);
73        }
74
75        try {
76            $this->upgrade->checkFilesystem();
77            return $this->json([
78                'success' => Translation::get(key: 'healthCheckOkay'),
79                'dateLastChecked' => $dateLastChecked,
80            ], Response::HTTP_OK);
81        } catch (Exception $exception) {
82            return $this->json([
83                'error' => $exception->getMessage(),
84                'dateLastChecked' => $dateLastChecked,
85            ], Response::HTTP_BAD_REQUEST);
86        }
87    }
88
89    #[Route(path: 'versions', name: 'admin.api.versions', methods: ['GET'])]
90    public function versions(): JsonResponse
91    {
92        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
93
94        try {
95            $versions = HttpClient::create(['timeout' => 30])->request('GET', 'https://api.phpmyfaq.de/versions');
96            return $this->json($versions->getContent(), Response::HTTP_OK);
97        } catch (
98            TransportExceptionInterface|ClientExceptionInterface|ServerExceptionInterface|RedirectionExceptionInterface $exception
99        ) {
100            return $this->json($exception->getMessage(), Response::HTTP_BAD_REQUEST);
101        }
102    }
103
104    /**
105     * @throws Exception|\Exception
106     */
107    #[Route(path: 'update-check', name: 'admin.api.update-check', methods: ['POST'])]
108    public function updateCheck(): JsonResponse
109    {
110        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
111
112        $dateTime = new DateTime();
113        $dateLastChecked = $dateTime->format(DateTimeInterface::ATOM);
114        $branch = (string) $this->configuration->get(item: 'upgrade.releaseEnvironment');
115
116        try {
117            $versions = $this->adminApi->getVersions();
118            $this->configuration->set('upgrade.dateLastChecked', $dateLastChecked);
119
120            $installed = $versions['installed'];
121            $available = $versions[$branch];
122
123            if (version_compare($installed, $available, operator: '<')) {
124                return $this->json([
125                    'version' => $available,
126                    'message' => Translation::getString(key: 'msgCurrentVersion') . $available,
127                    'dateLastChecked' => $dateLastChecked,
128                ], Response::HTTP_OK);
129            }
130
131            if ($branch !== 'nightly' && version_compare($installed, $available, operator: '>')) {
132                return $this->json([
133                    'version' => $available,
134                    'message' => Translation::get(key: 'msgInstalledNewerThanAvailable'),
135                    'dateLastChecked' => $dateLastChecked,
136                ], Response::HTTP_CONFLICT);
137            }
138
139            return $this->json([
140                'version' => $installed,
141                'message' => Translation::get(key: 'versionIsUpToDate'),
142                'dateLastChecked' => $dateLastChecked,
143            ], Response::HTTP_OK);
144        } catch (TransportExceptionInterface|DecodingExceptionInterface $e) {
145            return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
146        }
147    }
148
149    /**
150     * @throws TransportExceptionInterface
151     * @throws ServerExceptionInterface
152     * @throws RedirectionExceptionInterface
153     * @throws ClientExceptionInterface
154     * @throws \JsonException
155     * @throws Exception|\Exception
156     */
157    #[Route(path: 'download-package/{versionNumber}', name: 'admin.api.download-package', methods: ['POST'])]
158    public function downloadPackage(Request $request): JsonResponse
159    {
160        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
161
162        if (!$this->isValidUpdatePackageToken($request)) {
163            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
164        }
165
166        $versionNumber = Filter::filterVar(
167            $request->attributes->get('versionNumber'),
168            FILTER_SANITIZE_SPECIAL_CHARS,
169            '',
170        );
171
172        try {
173            $pathToPackage = $this->upgrade->downloadPackage($versionNumber);
174        } catch (Exception $exception) {
175            return $this->json(['error' => $exception->getMessage()], Response::HTTP_BAD_REQUEST);
176        }
177
178        if (!$this->upgrade->isNightly()) {
179            $result = $this->upgrade->verifyPackage($pathToPackage, $versionNumber);
180            if ($result === false) {
181                return $this->json([
182                    'error' => Translation::get(key: 'verificationFailure'),
183                ], Response::HTTP_BAD_GATEWAY);
184            }
185        }
186
187        $this->configuration->set('upgrade.lastDownloadedPackage', urlencode($pathToPackage));
188
189        return $this->json(['success' => Translation::get(key: 'downloadSuccessful')], Response::HTTP_OK);
190    }
191
192    #[Route(path: 'extract-package', name: 'admin.api.extract-package', methods: ['POST'])]
193    public function extractPackage(Request $request): Response
194    {
195        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
196
197        if (!$this->isValidUpdatePackageToken($request)) {
198            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
199        }
200
201        $pathToPackage = urldecode((string) $this->configuration->get(item: 'upgrade.lastDownloadedPackage'));
202
203        return new StreamedResponse(function () use ($pathToPackage): void {
204            $progressCallback = static function ($progress): void {
205                echo (string) json_encode(['progress' => $progress]) . "\n";
206                ob_flush();
207                flush();
208            };
209            try {
210                if ($this->upgrade->extractPackage($pathToPackage, $progressCallback)) {
211                    echo json_encode(['message' => Translation::get(key: 'extractSuccessful')]);
212                    return;
213                }
214
215                echo json_encode(['error' => Translation::get(key: 'extractFailure')]);
216            } catch (Exception $exception) {
217                echo
218                    json_encode([
219                        'error' => Translation::getString(key: 'extractFailure') . ' ' . $exception->getMessage(),
220                    ])
221                ;
222            }
223        });
224    }
225
226    #[Route(path: 'create-temporary-backup', name: 'admin.api.create-temporary-backup', methods: ['POST'])]
227    public function createTemporaryBackup(Request $request): Response
228    {
229        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
230
231        if (!$this->isValidUpdatePackageToken($request)) {
232            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
233        }
234
235        $backupHash = bin2hex(random_bytes(16));
236
237        return new StreamedResponse(function () use ($backupHash): void {
238            $progressCallback = static function ($progress): void {
239                echo (string) json_encode(['progress' => $progress]) . "\n";
240                ob_flush();
241                flush();
242            };
243            try {
244                if ($this->upgrade->createTemporaryBackup($backupHash . '.zip', $progressCallback)) {
245                    echo json_encode(['success' => 'Backup successful']);
246                    return;
247                }
248
249                echo json_encode(['error' => 'Backup failed']);
250            } catch (Exception $exception) {
251                echo json_encode(['error' => 'Backup failed: ' . $exception->getMessage()]);
252            }
253        });
254    }
255
256    #[Route(path: 'install-package', name: 'admin.api.install-package', methods: ['POST'])]
257    public function installPackage(Request $request): Response
258    {
259        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
260
261        if (!$this->isValidUpdatePackageToken($request)) {
262            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
263        }
264
265        return new StreamedResponse(function (): void {
266            $progressCallback = static function ($progress): void {
267                echo (string) json_encode(['progress' => $progress]) . "\n";
268                ob_flush();
269                flush();
270            };
271            try {
272                if (
273                    $this->upgrade->installPackage($progressCallback)
274                    && $this->configurator->adjustRewriteBaseHtaccess()
275                ) {
276                    echo json_encode(['success' => 'Package successfully installed.']);
277                    return;
278                }
279
280                echo json_encode(['error' => 'Install package failed']);
281            } catch (Exception $exception) {
282                echo json_encode(['error' => 'Install package failed: ' . $exception->getMessage()]);
283            }
284        });
285    }
286
287    #[Route(path: 'update-database', name: 'admin.api.update-database', methods: ['POST'])]
288    public function updateDatabase(Request $request): JsonResponse
289    {
290        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
291
292        if (!$this->isValidUpdatePackageToken($request)) {
293            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
294        }
295
296        $this->update->version = (string) $this->configuration->get('main.currentVersion');
297
298        try {
299            if ($this->update->applyUpdates()) {
300                $this->configuration->set('main.maintenanceMode', 'false');
301                return new JsonResponse(['success' => 'Database successfully updated.'], Response::HTTP_OK);
302            }
303
304            $this->configuration->set('main.maintenanceMode', 'false');
305            return new JsonResponse(['error' => 'Update database failed.'], Response::HTTP_BAD_GATEWAY);
306        } catch (Exception|\Exception $exception) {
307            $this->configuration->set('main.maintenanceMode', 'false');
308            return new JsonResponse([
309                'error' => 'Update database failed: ' . $exception->getMessage(),
310            ], Response::HTTP_BAD_GATEWAY);
311        }
312    }
313
314    /**
315     * @throws Exception|\Exception
316     */
317    #[Route(path: 'cleanup', name: 'admin.api.cleanup', methods: ['POST'])]
318    public function cleanUp(Request $request): JsonResponse
319    {
320        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
321
322        if (!$this->isValidUpdatePackageToken($request)) {
323            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
324        }
325
326        $this->upgrade->cleanUp();
327
328        return $this->json(['message' => 'Cleanup successful.'], Response::HTTP_OK);
329    }
330
331    /**
332     * Verifies the CSRF token sent with the updater package actions.
333     */
334    private function isValidUpdatePackageToken(Request $request): bool
335    {
336        $data = json_decode($request->getContent());
337        $csrfToken = is_object($data) ? (string) ($data->csrf ?? '') : '';
338
339        return Token::getInstance($this->session)->verifyToken('update-package', $csrfToken);
340    }
341}

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    }