Lines 92.28% 383 / 415
Methods 63.63% 21 / 33
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 indexCustomPage 100.00% 14 / 14 100.00% 1 / 1 5
 updateCustomPageIndex 100.00% 14 / 14 100.00% 1 / 1 5
 deleteCustomPageFromIndex 100.00% 14 / 14 100.00% 1 / 1 5
 create 90.24% 74 / 82 0.00% 0 / 1 23.49
 delete 89.47% 17 / 19 0.00% 0 / 1 7.06
 update 92.06% 58 / 63 0.00% 0 / 1 15.11
 activate 90.00% 27 / 30 0.00% 0 / 1 10.10
 checkSlug 90.90% 20 / 22 0.00% 0 / 1 7.04
 list 100.00% 21 / 21 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
39final class PageController extends AbstractAdministrationApiController
40{
41    public function __construct(
42        private readonly Elasticsearch $elasticsearch,
43        private readonly OpenSearch $openSearch,
44    ) {
45        parent::__construct();
46    }
47
48    /**
49     * Index a custom page in Elasticsearch and OpenSearch
50     *
51     * @param array<string, mixed> $pageData
52     * @throws Exception
53     */
54    private function indexCustomPage(array $pageData): void
55    {
56        // Index in Elasticsearch if enabled
57        if ($this->configuration->get(item: 'search.enableElasticsearch')) {
58            try {
59                $this->elasticsearch->indexCustomPage($pageData);
60            } catch (Exception $e) {
61                $this->configuration->getLogger()->error('Failed to index custom page in Elasticsearch', [
62                    'error' => $e->getMessage(),
63                    'page_id' => $pageData['id'] ?? null,
64                ]);
65            }
66        }
67
68        // Index in OpenSearch if enabled
69        if ($this->configuration->get(item: 'search.enableOpenSearch')) {
70            try {
71                $this->openSearch->indexCustomPage($pageData);
72            } catch (Exception $e) {
73                $this->configuration->getLogger()->error('Failed to index custom page in OpenSearch', [
74                    'error' => $e->getMessage(),
75                    'page_id' => $pageData['id'] ?? null,
76                ]);
77            }
78        }
79    }
80
81    /**
82     * Update a custom page in Elasticsearch and OpenSearch
83     *
84     * @param array<string, mixed> $pageData
85     * @throws Exception
86     */
87    private function updateCustomPageIndex(array $pageData): void
88    {
89        // Update in Elasticsearch if enabled
90        if ($this->configuration->get(item: 'search.enableElasticsearch')) {
91            try {
92                $this->elasticsearch->updateCustomPage($pageData);
93            } catch (Exception $e) {
94                $this->configuration->getLogger()->error('Failed to update custom page in Elasticsearch', [
95                    'error' => $e->getMessage(),
96                    'page_id' => $pageData['id'] ?? null,
97                ]);
98            }
99        }
100
101        // Update in OpenSearch if enabled
102        if ($this->configuration->get(item: 'search.enableOpenSearch')) {
103            try {
104                $this->openSearch->updateCustomPage($pageData);
105            } catch (Exception $e) {
106                $this->configuration->getLogger()->error('Failed to update custom page in OpenSearch', [
107                    'error' => $e->getMessage(),
108                    'page_id' => $pageData['id'] ?? null,
109                ]);
110            }
111        }
112    }
113
114    /**
115     * Delete a custom page from Elasticsearch and OpenSearch
116     */
117    private function deleteCustomPageFromIndex(int $pageId, string $lang): void
118    {
119        // Delete it from Elasticsearch if enabled
120        if ($this->configuration->get(item: 'search.enableElasticsearch')) {
121            try {
122                $this->elasticsearch->deleteCustomPage($pageId, $lang);
123            } catch (Exception $e) {
124                $this->configuration->getLogger()->error('Failed to delete custom page from Elasticsearch', [
125                    'error' => $e->getMessage(),
126                    'page_id' => $pageId,
127                ]);
128            }
129        }
130
131        // Delete from OpenSearch if enabled
132        if ($this->configuration->get(item: 'search.enableOpenSearch')) {
133            try {
134                $this->openSearch->deleteCustomPage($pageId, $lang);
135            } catch (Exception $e) {
136                $this->configuration->getLogger()->error('Failed to delete custom page from OpenSearch', [
137                    'error' => $e->getMessage(),
138                    'page_id' => $pageId,
139                ]);
140            }
141        }
142    }
143
144    /**
145     * @throws Exception
146     */
147    #[Route(path: 'page/create', name: 'admin.api.page.create', methods: ['POST'])]
148    public function create(Request $request): JsonResponse
149    {
150        $this->userHasPermission(PermissionType::PAGE_ADD);
151
152        $data = json_decode($request->getContent());
153
154        if (json_last_error() !== JSON_ERROR_NONE || !$data instanceof stdClass) {
155            return $this->json(['error' => 'Invalid JSON: ' . json_last_error_msg()], Response::HTTP_BAD_REQUEST);
156        }
157
158        $customPage = new CustomPage($this->configuration);
159
160        if (!Token::getInstance($this->session)->verifyToken(
161            page: 'save-page',
162            requestToken: (string) ($data->csrfToken ?? ''),
163        )) {
164            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
165        }
166
167        // Validate required fields
168        $requiredFields = ['pageTitle', 'slug', 'authorName', 'authorEmail', 'lang'];
169        foreach ($requiredFields as $field) {
170            if (!(($data->$field ?? null) === null || $data->$field === '')) {
171                continue;
172            }
173
174            return $this->json(['error' => "Missing required field: {$field}"], Response::HTTP_BAD_REQUEST);
175        }
176
177        $pageTitle = Filter::filterVar($data->pageTitle, FILTER_SANITIZE_SPECIAL_CHARS, '');
178        $slug = Filter::filterVar($data->slug, FILTER_SANITIZE_SPECIAL_CHARS, '');
179        $content = Filter::filterVar($data->content ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
180        $authorName = Filter::filterVar($data->authorName, FILTER_SANITIZE_SPECIAL_CHARS, '');
181        $authorEmail = Filter::filterEmail($data->authorEmail);
182        if (!is_string($authorEmail) || $authorEmail === '') {
183            return $this->json(['error' => 'Missing required field: authorEmail'], Response::HTTP_BAD_REQUEST);
184        }
185        $active = Filter::filterVar($data->active ?? false, FILTER_SANITIZE_SPECIAL_CHARS);
186        $language = Filter::filterVar($data->lang, FILTER_SANITIZE_SPECIAL_CHARS, '');
187        $seoTitle = Filter::filterVar($data->seoTitle ?? null, FILTER_SANITIZE_SPECIAL_CHARS);
188        $seoDescription = Filter::filterVar($data->seoDescription ?? null, FILTER_SANITIZE_SPECIAL_CHARS);
189        $seoRobots = Filter::filterVar($data->seoRobots ?? 'index,follow', FILTER_SANITIZE_SPECIAL_CHARS, '');
190
191        // Check if this is a translation (pageId provided)
192        $rawPageId = $data->pageId ?? null;
193        $isTranslation = $rawPageId !== null && (int) $rawPageId > 0;
194        $translationPageId = $isTranslation ? Filter::filterVar($rawPageId, FILTER_VALIDATE_INT) : null;
195
196        // For translations, check if language already exists for this page ID
197        if ($isTranslation) {
198            if ($translationPageId === null) {
199                return $this->json([
200                    'error' => Translation::get(key: 'ad_page_insertfail'),
201                ], Response::HTTP_BAD_REQUEST);
202            }
203
204            $existingLanguages = $customPage->getExistingLanguages($translationPageId);
205            if (in_array($language, $existingLanguages, strict: true)) {
206                return $this->json([
207                    'error' => 'Translation for this language already exists',
208                ], Response::HTTP_CONFLICT);
209            }
210        }
211
212        // Check if slug exists
213        if ($customPage->slugExists($slug, $language)) {
214            return $this->json(['error' => Translation::get(key: 'ad_page_slug_exists')], Response::HTTP_CONFLICT);
215        }
216
217        $pageEntity = new CustomPageEntity();
218        $pageEntity
219            ->setLanguage($language)
220            ->setPageTitle($pageTitle)
221            ->setSlug($slug)
222            ->setContent(Filter::removeAttributes(html_entity_decode(
223                (string) $content,
224                ENT_QUOTES | ENT_HTML5,
225                encoding: 'UTF-8',
226            )))
227            ->setAuthorName($authorName)
228            ->setAuthorEmail($authorEmail)
229            ->setActive((bool) $active)
230            ->setSeoTitle($seoTitle !== '' ? $seoTitle : null)
231            ->setSeoDescription($seoDescription !== '' ? $seoDescription : null)
232            ->setSeoRobots($seoRobots)
233            ->setCreated(new DateTime());
234
235        // Create a translation or new page
236        $success = false;
237        $pageId = 0;
238        if ($isTranslation && $translationPageId !== null) {
239            $success = $customPage->createTranslation($pageEntity, $translationPageId);
240            $pageId = $success ? $translationPageId : 0;
241        }
242
243        if (!$isTranslation) {
244            $pageId = $customPage->create($pageEntity);
245            $success = $pageId > 0;
246        }
247
248        if ($success) {
249            $this->adminLog->log($this->currentUser, AdminLogType::PAGE_ADD->value);
250
251            // Index in Elasticsearch/OpenSearch
252            $this->indexCustomPage([
253                'id' => $pageId,
254                'lang' => $language,
255                'page_title' => $pageTitle,
256                'content' => $pageEntity->getContent(),
257                'slug' => $slug,
258                'active' => $active ? 'y' : 'n',
259            ]);
260
261            return $this->json([
262                'success' => Translation::get(key: 'ad_page_updatesuc'),
263                'id' => $pageId,
264            ], Response::HTTP_OK);
265        }
266
267        return $this->json(['error' => Translation::get(key: 'ad_page_insertfail')], Response::HTTP_BAD_GATEWAY);
268    }
269
270    /**
271     * @throws Exception
272     */
273    #[Route(path: 'page/delete', name: 'admin.api.page.delete', methods: ['DELETE'])]
274    public function delete(Request $request): JsonResponse
275    {
276        $this->userHasPermission(PermissionType::PAGE_DELETE);
277
278        $data = json_decode($request->getContent());
279
280        if (json_last_error() !== JSON_ERROR_NONE || !$data instanceof stdClass) {
281            return $this->json(['error' => 'Invalid JSON: ' . json_last_error_msg()], Response::HTTP_BAD_REQUEST);
282        }
283
284        $customPage = new CustomPage($this->configuration);
285
286        if (!Token::getInstance($this->session)->verifyToken(
287            page: 'delete-page',
288            requestToken: (string) ($data->csrfToken ?? ''),
289        )) {
290            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
291        }
292
293        // Validate required fields
294        if (($data->id ?? null) === null || ($data->lang ?? null) === null) {
295            return $this->json(['error' => 'Missing required fields: id, lang'], Response::HTTP_BAD_REQUEST);
296        }
297
298        $deleteId = Filter::filterVar($data->id, FILTER_VALIDATE_INT);
299        $language = Filter::filterVar($data->lang, FILTER_SANITIZE_SPECIAL_CHARS, '');
300
301        if ($customPage->delete((int) $deleteId, $language)) {
302            $this->adminLog->log($this->currentUser, AdminLogType::PAGE_DELETE->value . ':' . (int) $deleteId);
303
304            // Delete from Elasticsearch/OpenSearch
305            $this->deleteCustomPageFromIndex((int) $deleteId, $language);
306
307            return $this->json(['success' => Translation::get(key: 'ad_page_delsuc')], Response::HTTP_OK);
308        }
309
310        return $this->json(['error' => Translation::get(key: 'ad_page_updatefail')], Response::HTTP_BAD_GATEWAY);
311    }
312
313    /**
314     * @throws Exception
315     */
316    #[Route(path: 'page/update', name: 'admin.api.page.update', methods: ['PUT'])]
317    public function update(Request $request): JsonResponse
318    {
319        $this->userHasPermission(PermissionType::PAGE_EDIT);
320
321        $data = json_decode($request->getContent());
322
323        if (json_last_error() !== JSON_ERROR_NONE || !$data instanceof stdClass) {
324            return $this->json(['error' => 'Invalid JSON: ' . json_last_error_msg()], Response::HTTP_BAD_REQUEST);
325        }
326
327        $customPage = new CustomPage($this->configuration);
328
329        if (!Token::getInstance($this->session)->verifyToken(
330            page: 'update-page',
331            requestToken: (string) ($data->csrfToken ?? ''),
332        )) {
333            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
334        }
335
336        // Validate required fields
337        $requiredFields = ['id', 'pageTitle', 'slug', 'authorName', 'authorEmail', 'lang'];
338        foreach ($requiredFields as $field) {
339            if (!(($data->$field ?? null) === null || $data->$field === '')) {
340                continue;
341            }
342
343            return $this->json(['error' => "Missing required field: {$field}"], Response::HTTP_BAD_REQUEST);
344        }
345
346        $pageId = Filter::filterVar($data->id, FILTER_VALIDATE_INT);
347
348        if ($pageId === null) {
349            return $this->json(['error' => Translation::get(key: 'ad_page_updatefail')], Response::HTTP_BAD_REQUEST);
350        }
351
352        $pageTitle = Filter::filterVar($data->pageTitle, FILTER_SANITIZE_SPECIAL_CHARS, '');
353        $slug = Filter::filterVar($data->slug, FILTER_SANITIZE_SPECIAL_CHARS, '');
354        $content = Filter::filterVar($data->content ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
355        $authorName = Filter::filterVar($data->authorName, FILTER_SANITIZE_SPECIAL_CHARS, '');
356        $authorEmail = Filter::filterEmail($data->authorEmail);
357        if (!is_string($authorEmail) || $authorEmail === '') {
358            return $this->json(['error' => 'Missing required field: authorEmail'], Response::HTTP_BAD_REQUEST);
359        }
360        $active = Filter::filterVar($data->active ?? false, FILTER_SANITIZE_SPECIAL_CHARS);
361        $language = Filter::filterVar($data->lang, FILTER_SANITIZE_SPECIAL_CHARS, '');
362        $seoTitle = Filter::filterVar($data->seoTitle ?? null, FILTER_SANITIZE_SPECIAL_CHARS);
363        $seoDescription = Filter::filterVar($data->seoDescription ?? null, FILTER_SANITIZE_SPECIAL_CHARS);
364        $seoRobots = Filter::filterVar($data->seoRobots ?? 'index,follow', FILTER_SANITIZE_SPECIAL_CHARS, '');
365
366        // Check if slug exists (excluding current page)
367        if ($customPage->slugExists($slug, $language, $pageId)) {
368            return $this->json(['error' => Translation::get(key: 'ad_page_slug_exists')], Response::HTTP_CONFLICT);
369        }
370
371        $pageEntity = new CustomPageEntity();
372        $pageEntity
373            ->setId($pageId)
374            ->setLanguage($language)
375            ->setPageTitle($pageTitle)
376            ->setSlug($slug)
377            ->setContent(Filter::removeAttributes(html_entity_decode(
378                (string) $content,
379                ENT_QUOTES | ENT_HTML5,
380                encoding: 'UTF-8',
381            )))
382            ->setAuthorName($authorName)
383            ->setAuthorEmail($authorEmail)
384            ->setActive((bool) $active)
385            ->setSeoTitle($seoTitle !== '' ? $seoTitle : null)
386            ->setSeoDescription($seoDescription !== '' ? $seoDescription : null)
387            ->setSeoRobots($seoRobots)
388            ->setCreated(new DateTime())
389            ->setUpdated(new DateTime());
390
391        if ($customPage->update($pageEntity)) {
392            $this->adminLog->log($this->currentUser, AdminLogType::PAGE_EDIT->value . ':' . $pageId);
393
394            // Update in Elasticsearch/OpenSearch
395            $this->updateCustomPageIndex([
396                'id' => $pageId,
397                'lang' => $language,
398                'page_title' => $pageTitle,
399                'content' => $pageEntity->getContent(),
400                'slug' => $slug,
401                'active' => $active ? 'y' : 'n',
402            ]);
403
404            return $this->json(['success' => Translation::get(key: 'ad_page_updatesuc')], Response::HTTP_OK);
405        }
406
407        return $this->json(['error' => Translation::get(key: 'ad_page_updatefail')], Response::HTTP_BAD_GATEWAY);
408    }
409
410    /**
411     * @throws Exception
412     */
413    #[Route(path: 'page/activate', name: 'admin.api.page.activate', methods: ['PUT'])]
414    public function activate(Request $request): JsonResponse
415    {
416        $this->userHasPermission(PermissionType::PAGE_EDIT);
417        $data = json_decode($request->getContent());
418
419        if (json_last_error() !== JSON_ERROR_NONE || !$data instanceof stdClass) {
420            return $this->json(['error' => 'Invalid JSON: ' . json_last_error_msg()], Response::HTTP_BAD_REQUEST);
421        }
422
423        $customPage = new CustomPage($this->configuration);
424
425        if (!Token::getInstance($this->session)->verifyToken(
426            page: 'activate-page',
427            requestToken: (string) ($data->csrfToken ?? ''),
428        )) {
429            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
430        }
431
432        // Validate required fields
433        if (($data->id ?? null) === null || ($data->status ?? null) === null) {
434            return $this->json(['error' => 'Missing required fields: id, status'], Response::HTTP_BAD_REQUEST);
435        }
436
437        $pageId = Filter::filterVar($data->id, FILTER_VALIDATE_INT);
438        $status = (bool) Filter::filterVar($data->status, FILTER_SANITIZE_SPECIAL_CHARS);
439
440        if ($pageId === null) {
441            return $this->json(['error' => Translation::get(key: 'ad_page_updatefail')], Response::HTTP_BAD_REQUEST);
442        }
443
444        if ($customPage->activate($pageId, $status)) {
445            $this->adminLog->log($this->currentUser, AdminLogType::PAGE_EDIT->value . ':' . $pageId);
446
447            // Get page data for indexing
448            $pageEntity = $customPage->getById($pageId);
449            if ($pageEntity) {
450                // Update in Elasticsearch/OpenSearch with new active status
451                $this->updateCustomPageIndex([
452                    'id' => $pageId,
453                    'lang' => $pageEntity->getLanguage(),
454                    'page_title' => $pageEntity->getPageTitle(),
455                    'content' => $pageEntity->getContent(),
456                    'slug' => $pageEntity->getSlug(),
457                    'active' => $status ? 'y' : 'n',
458                ]);
459            }
460
461            return $this->json(['success' => Translation::get(key: 'ad_page_updatesuc')], Response::HTTP_OK);
462        }
463
464        return $this->json(['error' => Translation::get(key: 'ad_page_updatefail')], Response::HTTP_BAD_GATEWAY);
465    }
466
467    /**
468     * Check if a slug is available
469     *
470     * @throws Exception
471     */
472    #[Route(path: 'page/check-slug', name: 'admin.api.page.check-slug', methods: ['POST'])]
473    public function checkSlug(Request $request): JsonResponse
474    {
475        $this->userHasPermission(PermissionType::PAGE_ADD);
476
477        $data = json_decode($request->getContent());
478
479        if (json_last_error() !== JSON_ERROR_NONE || !$data instanceof stdClass) {
480            return $this->json(['error' => 'Invalid JSON: ' . json_last_error_msg()], Response::HTTP_BAD_REQUEST);
481        }
482
483        $customPage = new CustomPage($this->configuration);
484
485        if (!Token::getInstance($this->session)->verifyToken(
486            page: 'save-page',
487            requestToken: (string) ($data->csrfToken ?? ''),
488        )) {
489            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
490        }
491
492        // Validate required fields
493        if (($data->slug ?? null) === null || ($data->lang ?? null) === null) {
494            return $this->json(['error' => 'Missing required fields: slug, lang'], Response::HTTP_BAD_REQUEST);
495        }
496
497        $slug = Filter::filterVar($data->slug, FILTER_SANITIZE_SPECIAL_CHARS, '');
498        $language = Filter::filterVar($data->lang, FILTER_SANITIZE_SPECIAL_CHARS, '');
499        $excludeId = ($data->excludeId ?? null) !== null
500            ? Filter::filterVar($data->excludeId, FILTER_VALIDATE_INT)
501            : null;
502
503        $exists = $customPage->slugExists($slug, $language, $excludeId);
504
505        return $this->json([
506            'available' => !$exists,
507            'slug' => $slug,
508        ], Response::HTTP_OK);
509    }
510
511    /**
512     * Get a paginated list of pages
513     *
514     * @throws Exception
515     */
516    #[Route(path: 'page/list', name: 'admin.api.page.list', methods: ['GET'])]
517    public function list(Request $request): JsonResponse
518    {
519        $this->userHasPermission(PermissionType::PAGE_EDIT);
520
521        $customPage = new CustomPage($this->configuration);
522
523        $limit = Filter::filterVar($request->query->get('limit'), FILTER_VALIDATE_INT, 25);
524        $offset = Filter::filterVar($request->query->get('offset'), FILTER_VALIDATE_INT, 0);
525        $sortField = Filter::filterVar($request->query->get('sortField'), FILTER_SANITIZE_SPECIAL_CHARS, 'created');
526        $sortOrder = Filter::filterVar($request->query->get('sortOrder'), FILTER_SANITIZE_SPECIAL_CHARS, 'DESC');
527        $activeOnly = (bool) Filter::filterVar($request->query->get('activeOnly'), FILTER_VALIDATE_BOOLEAN, false);
528
529        $pages = $customPage->getPagesPaginated(
530            activeOnly: $activeOnly,
531            limit: $limit,
532            offset: $offset,
533            sortField: $sortField,
534            sortOrder: $sortOrder,
535        );
536
537        $total = $customPage->countPages(activeOnly: $activeOnly);
538
539        return $this->json([
540            'data' => $pages,
541            'total' => $total,
542            'limit' => $limit,
543            'offset' => $offset,
544        ], Response::HTTP_OK);
545    }
546}

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    }