Lines 82.94% 496 / 598
Methods 61.76% 21 / 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
 create 68.62% 105 / 153 0.00% 0 / 1 28.00
 update 78.57% 110 / 140 0.00% 0 / 1 28.21
 listPermissions 100.00% 7 / 7 100.00% 1 / 1 1
 listByCategory 100.00% 18 / 18 100.00% 1 / 1 1
 activate 100.00% 23 / 23 100.00% 1 / 1 7
 sticky 100.00% 22 / 22 100.00% 1 / 1 7
 delete 88.23% 15 / 17 0.00% 0 / 1 3.01
 search 95.23% 20 / 21 0.00% 0 / 1 3
 saveOrderOfStickyFaqs 94.44% 17 / 18 0.00% 0 / 1 4.00
 import 81.39% 35 / 43 0.00% 0 / 1 11.78
 [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
68final class FaqController extends AbstractAdministrationApiController
69{
70    /* @mago-expect lint:excessive-parameter-list - the endpoint dependencies are injected explicitly; a service split is planned with the admin API rework */
71    public function __construct(
72        private readonly Faq $faq,
73        private readonly FaqAdministration $adminFaq,
74        private readonly Tags $tags,
75        private readonly Notification $notification,
76        private readonly Changelog $changelog,
77        private readonly Visits $visits,
78        private readonly Seo $seo,
79        private readonly Question $question,
80        private readonly AdminLog $logging,
81        private readonly WebPushService $webPushService,
82    ) {
83        parent::__construct();
84    }
85
86    /**
87     * @throws \phpMyFAQ\Core\Exception
88     * @throws Exception
89     */
90    /* @mago-expect lint:halstead - validates and persists the full FAQ payload in one endpoint */
91    #[Route(path: 'faq/create', name: 'admin.api.faq.create', methods: ['POST'])]
92    public function create(Request $request): JsonResponse
93    {
94        $this->userHasPermission(PermissionType::FAQ_ADD);
95
96        [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
97
98        $this->tags->setBypassPermissionCheck();
99        $categoryPermission = new CategoryPermission($this->configuration);
100        $faqPermission = new FaqPermission($this->configuration);
101
102        $category = new Category($this->configuration, [], withPermission: false);
103        $category->setUser($currentUser);
104        $category->setGroups($currentGroups);
105
106        $data = $this->getJsonObject($request)->data ?? null;
107        if (!$data instanceof stdClass) {
108            return $this->json(['error' => 'The request body must contain a data object.'], Response::HTTP_BAD_REQUEST);
109        }
110
111        if (!Token::getInstance($this->session)->verifyToken(
112            page: 'pmf-csrf-token',
113            requestToken: (string) ($data->{'pmf-csrf-token'} ?? ''),
114        )) {
115            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
116        }
117
118        // Collect FAQ data
119        $question = Filter::filterVar($data->question ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
120
121        $rawCategories = $data->{'categories[]'} ?? null;
122        $categories = is_array($rawCategories)
123            ? array_map(static fn(mixed $categoryId): int => (int) $categoryId, $rawCategories)
124            : [(int) Filter::filterVar($rawCategories, FILTER_VALIDATE_INT)];
125
126        $language = Filter::filterVar($data->lang ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
127        $tags = Filter::filterVar($data->tags ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
128        $active = Filter::filterVar($data->active ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no');
129        $sticky = Filter::filterVar($data->sticky ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no');
130        $content = Filter::filterVar($data->answer ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
131        $keywords = Filter::filterVar($data->keywords ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
132        $author = Filter::filterVar($data->author ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
133        $email = (string) Filter::filterEmail($data->email ?? '', default: '');
134        $comment = Filter::filterVar($data->comment ?? 'n', FILTER_SANITIZE_SPECIAL_CHARS, 'n');
135        $changed = Filter::filterVar($data->changed ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
136        $notes = Filter::filterVar($data->notes ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
137
138        $serpTitle = Filter::filterVar($data->serpTitle ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
139        $serpDescription = Filter::filterVar($data->serpDescription ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
140
141        // Permissions
142        $permissions = $faqPermission->createPermissionArray();
143
144        $this->adminLog->log($this->currentUser, AdminLogType::FAQ_ADD->value);
145
146        if ($question === '' && $content === '') {
147            return $this->json(['error' => Translation::get(key: 'msgNoQuestionAndAnswer')], Response::HTTP_CONFLICT);
148        }
149
150        $faqData = new FaqEntity();
151        $faqData
152            ->setLanguage($language)
153            ->setActive($active === 'yes')
154            ->setSticky($sticky !== 'no')
155            ->setQuestion(Filter::removeAttributes(html_entity_decode(
156                $question,
157                ENT_QUOTES | ENT_HTML5,
158                encoding: 'UTF-8',
159            )))
160            ->setAnswer(Filter::removeAttributes(html_entity_decode(
161                $content,
162                ENT_QUOTES | ENT_HTML5,
163                encoding: 'UTF-8',
164            )))
165            ->setKeywords($keywords)
166            ->setAuthor($author)
167            ->setEmail($email)
168            ->setComment($comment === 'y')
169            ->setCreatedDate(new DateTime())
170            ->setNotes(Filter::removeAttributes($notes));
171
172        // Add a new record and get that ID
173        $faqData = $this->faq->create($faqData);
174
175        $faqId = $faqData->getId();
176        if ($faqId) {
177            // Create ChangeLog entry
178            $this->changelog->add($faqId, $this->currentUser->getUserId(), nl2br($changed), $faqData->getLanguage());
179
180            // Create the visit entry
181            $this->visits->logViews($faqId);
182
183            $categoryRelation = new Relation($this->configuration, $category);
184            $categoryRelation->add($categories, $faqId, $faqData->getLanguage());
185
186            // Insert the tags
187            if ($tags !== '') {
188                $this->tags->create($faqId, explode(separator: ',', string: trim($tags)));
189            }
190
191            // Add user permissions
192            $faqPermission->add(FaqPermission::USER, $faqId, $permissions['restricted_user']);
193            $categoryPermission->add(CategoryPermission::USER, $categories, $permissions['restricted_user']);
194            // Add group permission
195            if ($this->configuration->get(item: 'security.permLevel') !== 'basic') {
196                $faqPermission->add(FaqPermission::GROUP, $faqId, $permissions['restricted_groups']);
197                $categoryPermission->add(CategoryPermission::GROUP, $categories, $permissions['restricted_groups']);
198            }
199
200            // Add the SEO data
201            $seoEntity = new SeoEntity();
202            $seoEntity
203                ->setSeoType(SeoType::FAQ)
204                ->setReferenceId($faqId)
205                ->setReferenceLanguage($faqData->getLanguage())
206                ->setTitle($serpTitle)
207                ->setDescription($serpDescription);
208            $this->seo->create($seoEntity);
209
210            // Open question answered
211            $openQuestionId = (int) Filter::filterVar($data->openQuestionId ?? null, FILTER_VALIDATE_INT);
212            if (0 !== $openQuestionId) {
213                if ($this->configuration->get(item: 'records.enableDeleteQuestion')) {
214                    // deletes question
215                    $this->question->delete($openQuestionId);
216                }
217
218                if (!$this->configuration->get(item: 'records.enableDeleteQuestion')) {
219                    // adds this faq record id to the related open question
220                    $this->question->updateQuestionAnswer($openQuestionId, $faqId, $categories[0] ?? 0);
221                }
222
223                $url = sprintf(
224                    '%scontent/%d/%d/%s/%s.html',
225                    $this->configuration->getDefaultUrl(),
226                    $categories[0] ?? 0,
227                    $faqId,
228                    $faqData->getLanguage(),
229                    TitleSlugifier::slug($faqData->getQuestion()),
230                );
231                $oLink = new Link($url, $this->configuration);
232
233                // notify the user who added the question
234                try {
235                    $notifyEmail = (string) Filter::filterVar($data->notifyEmail ?? '', FILTER_SANITIZE_EMAIL, '');
236                    $notifyUser = Filter::filterVar($data->notifyUser ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
237                    $this->notification->sendOpenQuestionAnswered($notifyEmail, $notifyUser, $oLink->toString());
238                } catch (Exception|TransportExceptionInterface $e) {
239                    $this->configuration
240                        ->getLogger()
241                        ->error('Send open question answered notification failed: ' . $e->getMessage());
242                }
243            }
244
245            // Let the admin and the category owners be informed by email of this new entry
246            try {
247                $categoryHelper = new CategoryHelper();
248                $categoryHelper->setCategory($category)->setConfiguration($this->configuration);
249                $moderators = $categoryHelper->getModerators($categories);
250                $this->notification->sendNewFaqAdded($moderators, $faqData);
251            } catch (Exception|TransportExceptionInterface $e) {
252                $this->configuration->getLogger()->error('Send moderator notification failed: ' . $e->getMessage());
253            }
254
255            // If Elasticsearch is enabled, index the new FAQ document
256            if ($this->configuration->get(item: 'search.enableElasticsearch')) {
257                $elasticsearch = new Elasticsearch($this->configuration);
258                $elasticsearch->index([
259                    'id' => $faqId,
260                    'lang' => $faqData->getLanguage(),
261                    'solution_id' => $faqData->getSolutionId(),
262                    'question' => $faqData->getQuestion(),
263                    'answer' => $faqData->getAnswer(),
264                    'keywords' => $faqData->getKeywords(),
265                    'category_id' => $categories[0] ?? 0,
266                ]);
267            }
268
269            // If OpenSearch is enabled, index the new FAQ document
270            if ($this->configuration->get(item: 'search.enableOpenSearch')) {
271                $openSearch = new OpenSearch($this->configuration);
272                $openSearch->index([
273                    'id' => $faqId,
274                    'lang' => $faqData->getLanguage(),
275                    'solution_id' => $faqData->getSolutionId(),
276                    'question' => $faqData->getQuestion(),
277                    'answer' => $faqData->getAnswer(),
278                    'keywords' => $faqData->getKeywords(),
279                    'category_id' => $categories[0] ?? 0,
280                ]);
281            }
282
283            // Send Web Push notification for new active FAQs.
284            // This is done here (not in Notification::sendNewFaqAdded) to provide
285            // the public FAQ URL, which is more useful for end-users.
286            if ($faqData->isActive()) {
287                try {
288                    $faqUrl = sprintf(
289                        '%scontent/%d/%d/%s/%s.html',
290                        $this->configuration->getDefaultUrl(),
291                        $categories[0] ?? 0,
292                        $faqId,
293                        $faqData->getLanguage(),
294                        TitleSlugifier::slug($faqData->getQuestion()),
295                    );
296                    $this->webPushService->sendToAll(
297                        Translation::getString('msgPushNewFaq'),
298                        $faqData->getQuestion(),
299                        $faqUrl,
300                        'new-faq-' . $faqId,
301                    );
302                } catch (\Throwable $e) {
303                    $this->configuration->getLogger()->error('Send web push notification failed: ' . $e->getMessage());
304                }
305            }
306
307            return $this->json([
308                'success' => Translation::get(key: 'ad_entry_savedsuc'),
309                'data' => $faqData->getJson(),
310            ], Response::HTTP_OK);
311        }
312
313        return $this->json(['error' => Translation::get(key: 'ad_entry_savedfail')], Response::HTTP_BAD_REQUEST);
314    }
315
316    /**
317     * @throws \phpMyFAQ\Core\Exception
318     * @throws Exception
319     */
320    /* @mago-expect lint:halstead - validates and persists the full FAQ payload in one endpoint */
321    #[Route(path: 'faq/update', name: 'admin.api.faq.update', methods: ['POST', 'PUT'])]
322    public function update(Request $request): JsonResponse
323    {
324        $this->userHasPermission(PermissionType::FAQ_EDIT);
325
326        [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
327
328        $this->tags->setBypassPermissionCheck();
329        $faqPermission = new FaqPermission($this->configuration);
330
331        $category = new Category($this->configuration, [], withPermission: false);
332        $category->setUser($currentUser);
333        $category->setGroups($currentGroups);
334
335        $data = $this->getJsonObject($request)->data ?? null;
336        if (!$data instanceof stdClass) {
337            return $this->json(['error' => 'The request body must contain a data object.'], Response::HTTP_BAD_REQUEST);
338        }
339
340        if (!Token::getInstance($this->session)->verifyToken(
341            page: 'pmf-csrf-token',
342            requestToken: (string) ($data->{'pmf-csrf-token'} ?? ''),
343        )) {
344            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
345        }
346
347        // Collect FAQ data
348        $faqId = (int) Filter::filterVar($data->faqId ?? null, FILTER_VALIDATE_INT);
349        $solutionId = (int) Filter::filterVar($data->solutionId ?? null, FILTER_VALIDATE_INT);
350        $revisionId = (int) Filter::filterVar($data->revisionId ?? null, FILTER_VALIDATE_INT);
351        $question = Filter::filterVar($data->question ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
352        $rawCategories = $data->{'categories[]'} ?? null;
353        $categories = is_array($rawCategories)
354            ? array_map(static fn(mixed $categoryId): int => (int) $categoryId, $rawCategories)
355            : [(int) Filter::filterVar($rawCategories, FILTER_VALIDATE_INT)];
356
357        $faqLang = Filter::filterVar($data->lang ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
358        $tags = Filter::filterVar($data->tags ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
359        $active = Filter::filterVar($data->active ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no');
360        $sticky = Filter::filterVar($data->sticky ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no');
361        $content = Filter::filterVar($data->answer ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
362        $keywords = Filter::filterVar($data->keywords ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
363        $author = Filter::filterVar($data->author ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
364        $email = (string) Filter::filterEmail($data->email ?? '', default: '');
365        $comment = Filter::filterVar($data->comment ?? 'n', FILTER_SANITIZE_SPECIAL_CHARS, 'n');
366        $changed = Filter::filterVar($data->changed ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
367        $date = Filter::filterVar($data->date ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
368        $notes = Filter::filterVar($data->notes ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
369        $revision = Filter::filterVar($data->revision ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no');
370        $recordDateHandling = Filter::filterVar($data->recordDateHandling ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
371
372        $serpTitle = Filter::filterVar($data->serpTitle ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
373        $serpDescription = Filter::filterVar($data->serpDescription ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
374
375        if ($question === '' && $content === '') {
376            return $this->json(['error' => Translation::get(key: 'msgNoQuestionAndAnswer')], Response::HTTP_CONFLICT);
377        }
378
379        // Permissions
380        $permissions = $faqPermission->createPermissionArray();
381
382        $this->logging->log($this->currentUser, AdminLogType::FAQ_EDIT->value . ':' . $faqId);
383        if ($active === 'yes') {
384            $this->logging->log($this->currentUser, AdminLogType::FAQ_PUBLISH->value . ':' . $faqId);
385        }
386
387        if ('yes' === $revision && true === $this->configuration->get(item: 'records.enableAutoRevisions')) {
388            $faqRevision = new Revision($this->configuration);
389            $faqRevision->create($faqId, $faqLang);
390            ++$revisionId;
391        }
392
393        $faqData = new FaqEntity();
394        $faqData
395            ->setId($faqId)
396            ->setLanguage($faqLang)
397            ->setRevisionId($revisionId)
398            ->setSolutionId($solutionId)
399            ->setActive($active === 'yes')
400            ->setSticky($sticky !== 'no')
401            ->setQuestion(Filter::removeAttributes(html_entity_decode(
402                $question,
403                ENT_QUOTES | ENT_HTML5,
404                encoding: 'UTF-8',
405            )))
406            ->setAnswer(Filter::removeAttributes(html_entity_decode(
407                $content,
408                ENT_QUOTES | ENT_HTML5,
409                encoding: 'UTF-8',
410            )))
411            ->setKeywords($keywords)
412            ->setAuthor($author)
413            ->setEmail($email)
414            ->setComment($comment === 'y')
415            ->setNotes(Filter::removeAttributes($notes));
416
417        switch ($recordDateHandling) {
418            case 'updateDate':
419                $faqData->setUpdatedDate(new DateTime());
420                break;
421            case 'manualDate':
422                $faqData->setUpdatedDate(new DateTime($date));
423                break;
424            case 'keepDate':
425                break;
426        }
427
428        // Create ChangeLog entry
429        $this->changelog->add($faqId, $this->currentUser->getUserId(), $changed, $faqLang, $revisionId);
430
431        // Create the visit entry
432        $this->visits->logViews($faqId);
433
434        // save or update the FAQ record
435        if ($this->faq->hasTranslation($faqId, $faqLang)) {
436            $faqData = $this->faq->update($faqData);
437        }
438
439        if (!$this->faq->hasTranslation($faqId, $faqLang)) {
440            $faqData = $this->faq->create($faqData);
441        }
442
443        $faqId = $faqData->getId() ?? $faqId;
444
445        $categoryRelation = new Relation($this->configuration, $category);
446        $categoryRelation->deleteByFaq($faqId, $faqLang);
447        $categoryRelation->add($categories, $faqId, $faqLang);
448
449        // Insert the tags
450        if ($tags !== '') {
451            $this->tags->create($faqId, explode(separator: ',', string: trim($tags)));
452        }
453
454        if ($tags === '') {
455            $this->tags->deleteByRecordId($faqId);
456        }
457
458        // Update the SEO data
459        $seoEntity = new SeoEntity();
460        $seoEntity
461            ->setSeoType(SeoType::FAQ)
462            ->setReferenceId($faqId)
463            ->setReferenceLanguage($faqLang)
464            ->setTitle($serpTitle)
465            ->setDescription($serpDescription);
466
467        if ($this->seo->get($seoEntity)->getId() === null) {
468            $seoEntity->setTitle($serpTitle)->setDescription($serpDescription);
469            $this->seo->create($seoEntity);
470        }
471
472        if ($this->seo->get($seoEntity)->getId() !== null) {
473            $seoEntity->setTitle($serpTitle)->setDescription($serpDescription);
474            $this->seo->update($seoEntity);
475        }
476
477        // Add user permissions
478        $faqPermission->delete(FaqPermission::USER, $faqId);
479        $faqPermission->add(FaqPermission::USER, $faqId, $permissions['restricted_user']);
480        // Add group permission
481        if ($this->configuration->get(item: 'security.permLevel') !== 'basic') {
482            $faqPermission->delete(FaqPermission::GROUP, $faqId);
483            $faqPermission->add(FaqPermission::GROUP, $faqId, $permissions['restricted_groups']);
484        }
485
486        // If Elasticsearch is enabled, update an active or delete inactive FAQ document
487        if ($this->configuration->get(item: 'search.enableElasticsearch')) {
488            $elasticsearch = new Elasticsearch($this->configuration);
489            if ('yes' === $active) {
490                $elasticsearch->update([
491                    'id' => $faqId,
492                    'lang' => $faqLang,
493                    'solution_id' => $faqData->getSolutionId(),
494                    'question' => $faqData->getQuestion(),
495                    'answer' => $faqData->getAnswer(),
496                    'keywords' => $faqData->getKeywords(),
497                    'category_id' => $categories[0] ?? 0,
498                ]);
499            }
500        }
501
502        // If OpenSearch is enabled, update an active or delete an inactive FAQ document
503        if ($this->configuration->get(item: 'search.enableOpenSearch')) {
504            $openSearch = new OpenSearch($this->configuration);
505            if ('yes' === $active) {
506                $openSearch->update([
507                    'id' => $faqId,
508                    'lang' => $faqLang,
509                    'solution_id' => $faqData->getSolutionId(),
510                    'question' => $faqData->getQuestion(),
511                    'answer' => $faqData->getAnswer(),
512                    'keywords' => $faqData->getKeywords(),
513                    'category_id' => $categories[0] ?? 0,
514                ]);
515            }
516        }
517
518        return $this->json([
519            'success' => Translation::get(key: 'ad_entry_savedsuc'),
520            'data' => $faqData->getJson(),
521        ], Response::HTTP_OK);
522    }
523
524    /**
525     * @throws Exception
526     */
527    #[Route(path: 'faq/permissions', name: 'admin.api.faq.permissions', methods: ['GET'])]
528    public function listPermissions(Request $request): JsonResponse
529    {
530        $this->userHasPermission(PermissionType::FAQ_EDIT);
531
532        $faqId = (int) Filter::filterVar($request->attributes->get(key: 'faqId'), FILTER_VALIDATE_INT);
533
534        $faqPermission = new FaqPermission($this->configuration);
535
536        return $this->json([
537            'user' => $faqPermission->get(FaqPermission::USER, $faqId),
538            'group' => $faqPermission->get(FaqPermission::GROUP, $faqId),
539        ], Response::HTTP_OK);
540    }
541
542    /**
543     * @throws Exception
544     */
545    #[Route(path: 'faqs/{categoryId}/{language}', name: 'admin.api.faqs', methods: ['GET'])]
546    public function listByCategory(Request $request): JsonResponse
547    {
548        $this->userHasPermission(PermissionType::FAQ_EDIT);
549
550        $categoryId = (int) Filter::filterVar($request->attributes->get(key: 'categoryId'), FILTER_VALIDATE_INT);
551        $language = Filter::filterVar($request->attributes->get(key: 'language'), FILTER_SANITIZE_SPECIAL_CHARS, '');
552
553        $onlyInactive = Filter::filterVar(
554            $request->query->get(key: 'only-inactive'),
555            FILTER_VALIDATE_BOOLEAN,
556            default: false,
557        );
558        $onlyNew = Filter::filterVar($request->query->get(key: 'only-new'), FILTER_VALIDATE_BOOLEAN, default: false);
559
560        $faq = new FaqAdministration($this->configuration);
561        $faq->setLanguage($language);
562
563        return $this->json([
564            'faqs' => $faq->getAllFaqsByCategory($categoryId, $onlyInactive, $onlyNew),
565            'isAllowedToTranslate' => $this->currentUser?->perm->hasPermission(
566                $this->currentUser->getUserId(),
567                PermissionType::FAQ_TRANSLATE->value,
568            ),
569        ], Response::HTTP_OK);
570    }
571
572    /**
573     * @throws Exception
574     */
575    #[Route(path: 'faq/activate', name: 'admin.api.faq.activate', methods: ['POST'])]
576    public function activate(Request $request): JsonResponse
577    {
578        $this->userHasPermission(PermissionType::FAQ_APPROVE);
579
580        $data = $this->getJsonObject($request);
581
582        $rawFaqIds = $data->faqIds ?? null;
583        $faqIds = is_array($rawFaqIds) ? array_map(static fn(mixed $faqId): int => (int) $faqId, $rawFaqIds) : [];
584        $faqLanguage = Filter::filterVar($data->faqLanguage ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
585        $checked = Filter::filterVar($data->checked ?? false, FILTER_VALIDATE_BOOLEAN, false);
586
587        if (!Token::getInstance($this->session)->verifyToken(
588            page: 'pmf-csrf-token',
589            requestToken: (string) ($data->csrf ?? ''),
590        )) {
591            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
592        }
593
594        if ($faqIds !== []) {
595            $faq = new FaqAdministration($this->configuration);
596            $success = false;
597
598            foreach ($faqIds as $faqId) {
599                if (!Language::isASupportedLanguage($faqLanguage)) {
600                    continue;
601                }
602
603                $success = $faq->updateRecordFlag($faqId, $faqLanguage, $checked, type: 'active');
604            }
605
606            if ($success) {
607                $this->adminLog->log($this->currentUser, AdminLogType::FAQ_EDIT->value);
608                return $this->json(['success' => Translation::get(key: 'ad_entry_savedsuc')], Response::HTTP_OK);
609            }
610
611            return $this->json(['error' => Translation::get(key: 'ad_entry_savedfail')], Response::HTTP_BAD_REQUEST);
612        }
613
614        return $this->json(['error' => 'No FAQ IDs provided.'], Response::HTTP_BAD_REQUEST);
615    }
616
617    /**
618     * @throws Exception
619     */
620    #[Route(path: 'faq/sticky', name: 'admin.api.faq.sticky', methods: ['POST'])]
621    public function sticky(Request $request): JsonResponse
622    {
623        $this->userHasPermission(PermissionType::FAQ_EDIT);
624
625        $data = $this->getJsonObject($request);
626
627        $rawFaqIds = $data->faqIds ?? null;
628        $faqIds = is_array($rawFaqIds) ? array_map(static fn(mixed $faqId): int => (int) $faqId, $rawFaqIds) : [];
629        $faqLanguage = Filter::filterVar($data->faqLanguage ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
630        $checked = Filter::filterVar($data->checked ?? false, FILTER_VALIDATE_BOOLEAN, false);
631
632        if (!Token::getInstance($this->session)->verifyToken(
633            page: 'pmf-csrf-token',
634            requestToken: (string) ($data->csrf ?? ''),
635        )) {
636            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
637        }
638
639        if ($faqIds !== []) {
640            $faq = new FaqAdministration($this->configuration);
641            $success = false;
642
643            foreach ($faqIds as $faqId) {
644                if (!Language::isASupportedLanguage($faqLanguage)) {
645                    continue;
646                }
647
648                $success = $faq->updateRecordFlag($faqId, $faqLanguage, $checked, type: 'sticky');
649            }
650
651            if ($success) {
652                return $this->json(['success' => Translation::get(key: 'ad_entry_savedsuc')], Response::HTTP_OK);
653            }
654
655            return $this->json(['error' => Translation::get(key: 'ad_entry_savedfail')], Response::HTTP_BAD_REQUEST);
656        }
657
658        return $this->json(['error' => 'No FAQ IDs provided.'], Response::HTTP_BAD_REQUEST);
659    }
660
661    /**
662     * @throws Exception
663     */
664    #[Route(path: 'faq/delete', name: 'admin.api.faq.delete', methods: ['DELETE'])]
665    public function delete(Request $request): JsonResponse
666    {
667        $this->userHasPermission(PermissionType::FAQ_DELETE);
668
669        $faq = new Faq($this->configuration);
670
671        $data = $this->getJsonObject($request);
672
673        $faqId = (int) Filter::filterVar($data->faqId ?? null, FILTER_VALIDATE_INT);
674        $faqLanguage = Filter::filterVar($data->faqLanguage ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
675
676        if (!Token::getInstance($this->session)->verifyToken(
677            page: 'pmf-csrf-token',
678            requestToken: (string) ($data->csrf ?? ''),
679        )) {
680            return $this->json([
681                'error' => 'CSRF Token - ' . Translation::getString(key: 'msgNoPermission'),
682            ], Response::HTTP_UNAUTHORIZED);
683        }
684
685        $this->adminLog->log($this->currentUser, AdminLogType::FAQ_DELETE->value . ':' . $faqId);
686
687        try {
688            $faq->delete($faqId, $faqLanguage);
689        } catch (FileException|AttachmentException $e) {
690            return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
691        }
692
693        return $this->json(['success' => Translation::get(key: 'ad_entry_delsuc')], Response::HTTP_OK);
694    }
695
696    /**
697     * @throws Exception
698     */
699    #[Route(path: 'faq/search', name: 'admin.api.faq.search', methods: ['POST'])]
700    public function search(Request $request): JsonResponse
701    {
702        $this->userHasPermission(PermissionType::FAQ_EDIT);
703
704        $data = $this->getJsonObject($request);
705
706        if (!Token::getInstance($this->session)->verifyToken(
707            page: 'pmf-csrf-token',
708            requestToken: (string) ($data->csrf ?? ''),
709        )) {
710            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
711        }
712
713        $faqPermission = new FaqPermission($this->configuration);
714        $faqSearch = new Search($this->configuration);
715        $faqSearch->setCategory(new Category($this->configuration));
716
717        $searchResultSet = new SearchResultSet($this->currentUser, $faqPermission, $this->configuration);
718        $searchString = Filter::filterVar($data->search ?? null, FILTER_SANITIZE_SPECIAL_CHARS);
719
720        if (is_string($searchString)) {
721            $searchResult = $faqSearch->search($searchString, allLanguages: false);
722
723            $searchResultSet->reviewResultSet($searchResult);
724
725            $searchHelper = new SearchHelper($this->configuration);
726            $searchHelper->setSearchTerm($searchString);
727
728            return $this->json([
729                'success' => $searchHelper->renderAdminSuggestionResult($searchResultSet),
730            ], Response::HTTP_OK);
731        }
732
733        return $this->json(['error' => 'No search string provided.'], Response::HTTP_BAD_REQUEST);
734    }
735
736    /**
737     * @throws Exception
738     */
739    #[Route(path: 'faqs/sticky/order', name: 'admin.api.faqs.sticky.order', methods: ['POST'])]
740    public function saveOrderOfStickyFaqs(Request $request): JsonResponse
741    {
742        $this->userHasPermission(PermissionType::FAQ_EDIT);
743
744        [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
745
746        $data = $this->getJsonObject($request);
747
748        if (!Token::getInstance($this->session)->verifyToken(
749            page: 'order-stickyfaqs',
750            requestToken: (string) ($data->csrf ?? ''),
751        )) {
752            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
753        }
754
755        $faqIds = $data->faqIds ?? null;
756        if (!is_array($faqIds)) {
757            return $this->json(['error' => 'No FAQ IDs provided.'], Response::HTTP_BAD_REQUEST);
758        }
759
760        if (!$this->adminFaq->setStickyFaqOrder(
761            array_values(array_map(intval(...), $faqIds)),
762            $currentUser,
763            $currentGroups,
764        )) {
765            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
766        }
767
768        return $this->json(['success' => Translation::get(key: 'ad_categ_save_order')], Response::HTTP_OK);
769    }
770
771    /**
772     * @throws Exception
773     */
774    #[Route(path: 'faq/import', name: 'admin.api.faq.import', methods: ['POST'])]
775    public function import(Request $request): JsonResponse
776    {
777        $this->userHasPermission(PermissionType::FAQ_ADD);
778
779        $file = $request->files->get(key: 'file');
780        if (!$file instanceof UploadedFile) {
781            return $this->json(['error' => 'Bad request: There is no file submitted.'], Response::HTTP_BAD_REQUEST);
782        }
783
784        if (!Token::getInstance($this->session)->verifyToken(
785            page: 'importfaqs',
786            requestToken: (string) $request->request->get(key: 'csrf'),
787        )) {
788            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
789        }
790
791        $faqImport = new Import($this->configuration);
792
793        $errors = [];
794
795        if (0 === $file->getError() && $faqImport->isCSVFile($file)) {
796            $handle = fopen(filename: (string) $file->getRealPath(), mode: 'r');
797            if ($handle === false) {
798                return $this->json(['error' => 'The uploaded file could not be read.'], Response::HTTP_BAD_REQUEST);
799            }
800
801            $csvData = $faqImport->parseCSV($handle);
802
803            if (!$faqImport->validateCSV($csvData)) {
804                $result = [
805                    'storedAll' => false,
806                    'error' => Translation::get(key: 'msgCSVFileNotValidated'),
807                ];
808                return $this->json($result, Response::HTTP_BAD_REQUEST);
809            }
810
811            foreach ($csvData as $index => $record) {
812                try {
813                    if (!$faqImport->import($record)) {
814                        $errors[] = sprintf('Row %d: import failed.', $index + 1);
815                    }
816                } catch (\Throwable $throwable) {
817                    $errors[] = sprintf('Row %d: %s', $index + 1, $throwable->getMessage());
818                }
819            }
820
821            if ($errors === []) {
822                $result = [
823                    'storedAll' => true,
824                    'success' => Translation::get(key: 'msgImportSuccessful'),
825                ];
826                return $this->json($result, Response::HTTP_OK);
827            }
828
829            $result = [
830                'storedAll' => false,
831                'messages' => $errors,
832            ];
833
834            return $this->json($result, Response::HTTP_BAD_REQUEST);
835        }
836
837        $result = [
838            'storedAll' => false,
839            'error' => 'Bad request: The file is not a CSV file.',
840        ];
841
842        return $this->json($result, Response::HTTP_BAD_REQUEST);
843    }
844}

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    }