Lines 92.54% 385 / 416
Methods 75.00% 33 / 44
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 getByCategoryId 100.00% 10 / 10 100.00% 1 / 1 2
 getById 100.00% 15 / 15 100.00% 1 / 1 6
 getByTagId 100.00% 11 / 11 100.00% 1 / 1 2
 getPopular 100.00% 9 / 9 100.00% 1 / 1 3
 getLatest 88.88% 8 / 9 0.00% 0 / 1 3.01
 getTrending 88.88% 8 / 9 0.00% 0 / 1 3.01
 getSticky 100.00% 9 / 9 100.00% 1 / 1 3
 list 83.33% 35 / 42 0.00% 0 / 1 7.23
 create 90.90% 60 / 66 0.00% 0 / 1 7.04
 update 90.00% 45 / 50 0.00% 0 / 1 3.01
 applyRequestLanguage 100.00% 4 / 4 100.00% 1 / 1 1
 resolveRequestLanguage 100.00% 7 / 7 100.00% 1 / 1 4
 withRequestLanguage 100.00% 7 / 7 100.00% 1 / 1 2
 [phpMyFAQ\Controller\Api\AbstractApiController] initializeFromContainer 100.00% 3 / 3 100.00% 1 / 1 2
 [phpMyFAQ\Controller\Api\AbstractApiController] getPaginationRequest 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Controller\Api\AbstractApiController] getSortRequest 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\Api\AbstractApiController] getFilterRequest 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\Api\AbstractApiController] paginatedResponse 100.00% 25 / 25 100.00% 1 / 1 3
 [phpMyFAQ\Controller\Api\AbstractApiController] apiResponse 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Controller\Api\AbstractApiController] errorResponse 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Controller\Api\AbstractApiController] createResponseEtag 100.00% 1 / 1 100.00% 1 / 1 1
 [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
41final class FaqController extends AbstractApiController
42{
43    public function __construct(
44        private readonly Faq $faq,
45        private readonly Tags $tags,
46        private readonly FaqStatistics $faqStatistics,
47        private readonly FaqMetaData $faqMetaData,
48        private readonly Language $language,
49    ) {
50        parent::__construct();
51    }
52
53    /**
54     * @throws \phpMyFAQ\Core\Exception|Exception
55     */
56    #[OA\Get(
57        path: '/api/v4.0/faqs/{categoryId}',
58        operationId: 'getByCategoryId',
59        description: 'This endpoint returns all the FAQs with a preview of the answer for the given category ID and '
60        . 'the language provided by "Accept-Language".',
61        tags: ['Public Endpoints'],
62    )]
63    #[OA\Header(
64        header: 'Accept-Language',
65        description: 'The language code for the FAQ.',
66        schema: new OA\Schema(type: 'string'),
67    )]
68    #[OA\Parameter(
69        name: 'categoryId',
70        description: 'The category ID.',
71        in: 'path',
72        required: true,
73        schema: new OA\Schema(type: 'integer'),
74    )]
75    #[OA\Response(
76        response: 200,
77        description: 'If the category returns at least one FAQ.',
78        content: new OA\JsonContent(example: [[
79            'record_id' => 1,
80            'record_lang' => 'en',
81            'category_id' => 1,
82            'record_title' => 'Is there life after death?',
83            'record_preview' => 'Maybe!',
84            'record_link' => '/phpmyfaq/content/1/1/en/is-there-life-after-death.html',
85            'record_updated' => '20191010175452',
86            'visits' => 3,
87            'record_created' => '2018-09-03T21:30:17+02:00',
88        ]]),
89    )]
90    #[OA\Response(response: 500, description: 'If fetching the FAQs fails.', content: new OA\JsonContent(example: [
91        'error' => 'Error message',
92    ]))]
93    #[Route(
94        path: 'v4.0/faqs/{categoryId}',
95        name: 'api.faqs.by-category-id',
96        requirements: ['categoryId' => '\d+'],
97        methods: ['GET'],
98    )]
99    public function getByCategoryId(Request $request): JsonResponse
100    {
101        return $this->withRequestLanguage($request, function () use ($request): JsonResponse {
102            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
103
104            $this->faq->setUser($currentUser);
105            $this->faq->setGroups($currentGroups);
106
107            $categoryId = (int) Filter::filterVar($request->attributes->get(key: 'categoryId'), FILTER_VALIDATE_INT);
108
109            try {
110                $result = $this->faq->getAllAvailableFaqsByCategoryId($categoryId);
111                return $this->json($result, Response::HTTP_OK);
112            } catch (Exception|CommonMarkException $exception) {
113                return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
114            }
115        });
116    }
117
118    /**
119     * @throws \phpMyFAQ\Core\Exception|Exception
120     */
121    #[OA\Get(
122        path: '/api/v4.0/faq/{categoryId}/{faqId}',
123        operationId: 'getFaqById',
124        description: 'This endpoint returns the FAQ for the given FAQ ID and the language provided by '
125        . '"Accept-Language".',
126        tags: ['Public Endpoints'],
127    )]
128    #[OA\Header(
129        header: 'Accept-Language',
130        description: 'The language code for the FAQ.',
131        schema: new OA\Schema(type: 'string'),
132    )]
133    #[OA\Parameter(
134        name: 'categoryId',
135        description: 'The category ID.',
136        in: 'path',
137        required: true,
138        schema: new OA\Schema(type: 'integer'),
139    )]
140    #[OA\Parameter(
141        name: 'faqId',
142        description: 'The FAQ ID.',
143        in: 'path',
144        required: true,
145        schema: new OA\Schema(type: 'integer'),
146    )]
147    #[OA\Response(response: 200, description: 'If the FAQ exists.', content: new OA\JsonContent(example: [
148        'id' => 1,
149        'lang' => 'en',
150        'solution_id' => 1000,
151        'revision_id' => 0,
152        'active' => 'yes',
153        'sticky' => 0,
154        'keywords' => '',
155        'question' => 'Is there life after death?',
156        'answer' => 'Maybe!',
157        'author' => 'phpMyFAQ User',
158        'email' => 'user@example.org',
159        'comment' => 'y',
160        'updated' => '2019-10-10 17:54',
161        'dateStart' => '00000000000000',
162        'dateEnd' => '99991231235959',
163        'created' => '2019-09-03T21:30:17+02:00',
164        'category_id' => 1,
165        'link' => 'https://localhost/content/1/1/en/is_there_life_after_death.html',
166    ]))]
167    #[OA\Response(
168        response: 404,
169        description: 'If there are no FAQs for the given FAQ ID.',
170        content: new OA\JsonContent(example: new \stdClass()),
171    )]
172    #[Route(path: 'v4.0/faq/{categoryId}/{faqId}', name: 'api.faq.by-id', methods: ['GET'])]
173    public function getById(Request $request): JsonResponse
174    {
175        return $this->withRequestLanguage($request, function () use ($request): JsonResponse {
176            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
177
178            $this->faq->setUser($currentUser);
179            $this->faq->setGroups($currentGroups);
180
181            $faqId = (int) Filter::filterVar($request->attributes->get(key: 'faqId'), FILTER_VALIDATE_INT);
182            $categoryId = (int) Filter::filterVar($request->attributes->get(key: 'categoryId'), FILTER_VALIDATE_INT);
183            $onlyActive = (bool) $this->configuration->get('api.onlyActiveFaqs');
184
185            $result = $this->faq->getFaqByIdAndCategoryId($faqId, $categoryId);
186
187            if (
188                (is_countable($result) ? count($result) : 0) === 0
189                || $result['solution_id'] === 42
190                || $onlyActive && $result['active'] !== 'yes'
191            ) {
192                $result = new stdClass();
193                return $this->json($result, Response::HTTP_NOT_FOUND);
194            }
195
196            return $this->json($result, Response::HTTP_OK);
197        });
198    }
199
200    /**
201     * @throws Exception
202     */
203    #[OA\Get(
204        path: '/api/v4.0/faqs/tags/{tagId}',
205        operationId: 'getByTagId',
206        description: 'This endpoint returns all the FAQs for the given tag ID and the language provided by '
207        . '
208        "Accept-Language"',
209        tags: ['Public Endpoints'],
210    )]
211    #[OA\Header(
212        header: 'Accept-Language',
213        description: 'The language code for the FAQ.',
214        schema: new OA\Schema(type: 'string'),
215    )]
216    #[OA\Parameter(
217        name: 'tagId',
218        description: 'The tag ID.',
219        in: 'path',
220        required: true,
221        schema: new OA\Schema(type: 'integer'),
222    )]
223    #[OA\Response(
224        response: 200,
225        description: 'If the tag ID returns at least one FAQ.',
226        content: new OA\JsonContent(example: [[
227            'record_id' => 1,
228            'record_lang' => 'en',
229            'category_id' => 1,
230            'record_title' => 'Is there life after death?',
231            'record_preview' => 'Maybe!',
232            'record_link' => '/phpmyfaq/content/1/1/en/is-there-life-after-death.html',
233            'record_updated' => '20191010175452',
234            'visits' => 3,
235            'record_created' => '2018-09-03T21:30:17+02:00',
236        ]]),
237    )]
238    #[OA\Response(
239        response: 500,
240        description: 'If fetching the tagged FAQs fails.',
241        content: new OA\JsonContent(example: ['error' => 'Error message']),
242    )]
243    #[Route(path: 'v4.0/faqs/tags/{tagId}', name: 'api.faqs.by-tag-id', methods: ['GET'])]
244    public function getByTagId(Request $request): JsonResponse
245    {
246        return $this->withRequestLanguage($request, function () use ($request): JsonResponse {
247            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
248
249            $this->faq->setUser($currentUser);
250            $this->faq->setGroups($currentGroups);
251
252            $tagId = (int) Filter::filterVar($request->attributes->get(key: 'tagId'), FILTER_VALIDATE_INT);
253
254            $recordIds = $this->tags->getFaqsByTagId($tagId);
255
256            try {
257                $result = $this->faq->getFaqsByIds($recordIds);
258                return $this->json($result, Response::HTTP_OK);
259            } catch (Exception $exception) {
260                return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
261            }
262        });
263    }
264
265    /**
266     * @throws \phpMyFAQ\Core\Exception|Exception
267     */
268    #[OA\Get(
269        path: '/api/v4.0/faqs/popular',
270        operationId: 'getPopular',
271        description: 'This endpoint returns the popular FAQs for the given language provided by "Accept-Language".',
272        tags: ['Public Endpoints'],
273    )]
274    #[OA\Header(
275        header: 'Accept-Language',
276        description: 'The language code for the FAQ.',
277        schema: new OA\Schema(type: 'string'),
278    )]
279    #[OA\Response(
280        response: 200,
281        description: "If there's at least one popular FAQ.",
282        content: new OA\JsonContent(example: [[
283            'date' => '2019-07-13T11:28:00+0200',
284            'question' => 'How can I survive without phpMyFAQ?',
285            'answer' => 'A good question!',
286            'visits' => 10,
287            'url' => 'https://www.example.org/content/1/36/de/how-can-i-survive-without-phpmyfaq.html',
288        ]]),
289    )]
290    #[OA\Response(
291        response: 404,
292        description: "If there's not a single popular FAQ.",
293        content: new OA\JsonContent(example: []),
294    )]
295    #[Route(path: 'v4.0/faqs/popular', name: 'api.faqs.popular', methods: ['GET'])]
296    public function getPopular(Request $request): JsonResponse
297    {
298        return $this->withRequestLanguage($request, function (): JsonResponse {
299            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
300
301            $this->faqStatistics->setUser($currentUser);
302            $this->faqStatistics->setGroups($currentGroups);
303
304            $result = array_values($this->faqStatistics->getTopTenData());
305
306            if ((is_countable($result) ? count($result) : 0) === 0) {
307                return $this->json($result, Response::HTTP_NOT_FOUND);
308            }
309
310            return $this->json($result, Response::HTTP_OK);
311        });
312    }
313
314    /**
315     * @throws \phpMyFAQ\Core\Exception
316     * @throws Exception
317     */
318    #[OA\Get(
319        path: 'v4.0/faqs/latest',
320        operationId: 'getLatest',
321        description: 'This endpoint returns the latest FAQs for the given language provided by "Accept-Language".',
322        tags: ['Public Endpoints'],
323    )]
324    #[OA\Header(
325        header: 'Accept-Language',
326        description: 'The language code for the FAQ.',
327        schema: new OA\Schema(type: 'string'),
328    )]
329    #[OA\Response(
330        response: 200,
331        description: "If there's at least one latest FAQ.",
332        content: new OA\JsonContent(example: [[
333            'date' => '2019-07-13T11:28:00+0200',
334            'question' => 'How can I survive without phpMyFAQ?',
335            'answer' => 'A good question!',
336            'visits' => 10,
337            'url' => 'https://www.example.org/content/1/36/de/how-can-i-survive-without-phpmyfaq.html',
338        ]]),
339    )]
340    #[OA\Response(
341        response: 404,
342        description: "If there's not one latest FAQ.",
343        content: new OA\JsonContent(example: []),
344    )]
345    #[Route(path: 'v4.0/faqs/latest', name: 'api.faqs.latest', methods: ['GET'])]
346    public function getLatest(Request $request): JsonResponse
347    {
348        return $this->withRequestLanguage($request, function (): JsonResponse {
349            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
350
351            $this->faqStatistics->setUser($currentUser);
352            $this->faqStatistics->setGroups($currentGroups);
353
354            $result = array_values($this->faqStatistics->getLatestData());
355
356            if ((is_countable($result) ? count($result) : 0) === 0) {
357                return $this->json($result, Response::HTTP_NOT_FOUND);
358            }
359
360            return $this->json($result, Response::HTTP_OK);
361        });
362    }
363
364    /**
365     * @throws \phpMyFAQ\Core\Exception|Exception
366     */
367    #[OA\Get(
368        path: '/api/v4.0/faqs/trending',
369        operationId: 'getTrending',
370        description: 'This endpoint returns the trending FAQs for the given language provided by "Accept-Language".',
371        tags: ['Public Endpoints'],
372    )]
373    #[OA\Header(
374        header: 'Accept-Language',
375        description: 'The language code for the FAQ.',
376        schema: new OA\Schema(type: 'string'),
377    )]
378    #[OA\Response(
379        response: 200,
380        description: "If there's at least one trending FAQ.",
381        content: new OA\JsonContent(example: [[
382            'date' => '2019-07-13T11:28:00+0200',
383            'question' => 'How can I survive without phpMyFAQ?',
384            'answer' => 'A good question!',
385            'visits' => 10,
386            'url' => 'https://www.example.org/content/1/36/de/how-can-i-survive-without-phpmyfaq.html',
387        ]]),
388    )]
389    #[OA\Response(
390        response: 404,
391        description: "If there's not a single trending FAQ.",
392        content: new OA\JsonContent(example: []),
393    )]
394    #[Route(path: 'v4.0/faqs/trending', name: 'api.faqs.trending', methods: ['GET'])]
395    public function getTrending(Request $request): JsonResponse
396    {
397        return $this->withRequestLanguage($request, function (): JsonResponse {
398            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
399
400            $this->faqStatistics->setUser($currentUser);
401            $this->faqStatistics->setGroups($currentGroups);
402
403            $result = array_values($this->faqStatistics->getTrendingData());
404
405            if ((is_countable($result) ? count($result) : 0) === 0) {
406                return $this->json($result, Response::HTTP_NOT_FOUND);
407            }
408
409            return $this->json($result, Response::HTTP_OK);
410        });
411    }
412
413    /**
414     * @throws \phpMyFAQ\Core\Exception|Exception
415     */
416    #[OA\Get(
417        path: '/api/v4.0/faqs/sticky',
418        operationId: 'getSticky',
419        description: 'This endpoint returns the sticky FAQs for the given language provided by "Accept-Language".',
420        tags: ['Public Endpoints'],
421    )]
422    #[OA\Header(
423        header: 'Accept-Language',
424        description: 'The language code for the FAQ.',
425        schema: new OA\Schema(type: 'string'),
426    )]
427    #[OA\Response(
428        response: 200,
429        description: "If there's at least one sticky FAQ.",
430        content: new OA\JsonContent(example: [
431            [
432                'question' => 'How can I survive without phpMyFAQ?',
433                'url' => 'https://www.example.org/content/1/36/de/how-can-i-survive-without-phpmyfaq.html',
434                'id' => 8,
435                'order' => 1,
436            ],
437            [
438                'question' => 'Is there life after death?',
439                'url' => 'https://www.example.org/content/1/1/de/is-there-life-after-death.html',
440                'id' => 10,
441                'order' => 2,
442            ],
443        ]),
444    )]
445    #[OA\Response(
446        response: 404,
447        description: "If there's not one sticky FAQ.",
448        content: new OA\JsonContent(example: []),
449    )]
450    #[Route(path: 'v4.0/faqs/sticky', name: 'api.faqs.sticky', methods: ['GET'])]
451    public function getSticky(Request $request): JsonResponse
452    {
453        return $this->withRequestLanguage($request, function (): JsonResponse {
454            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
455
456            $this->faq->setUser($currentUser);
457            $this->faq->setGroups($currentGroups);
458
459            $result = array_values($this->faq->getStickyFaqsData());
460
461            if ((is_countable($result) ? count($result) : 0) === 0) {
462                return $this->json($result, Response::HTTP_NOT_FOUND);
463            }
464
465            return $this->json($result, Response::HTTP_OK);
466        });
467    }
468
469    /**
470     * @throws \phpMyFAQ\Core\Exception|Exception
471     */
472    #[OA\Get(
473        path: '/api/v4.0/faqs',
474        operationId: 'getAll',
475        description: 'This endpoint returns paginated FAQs for the given language provided by "Accept-Language".',
476        tags: ['Public Endpoints'],
477    )]
478    #[OA\Header(
479        header: 'Accept-Language',
480        description: 'The language code for the FAQ.',
481        schema: new OA\Schema(type: 'string'),
482    )]
483    #[OA\Parameter(
484        name: 'page',
485        description: 'Page number for pagination (page-based)',
486        in: 'query',
487        required: false,
488        schema: new OA\Schema(type: 'integer', default: 1),
489    )]
490    #[OA\Parameter(
491        name: 'per_page',
492        description: 'Items per page (page-based, max 100)',
493        in: 'query',
494        required: false,
495        schema: new OA\Schema(type: 'integer', default: 25),
496    )]
497    #[OA\Parameter(
498        name: 'limit',
499        description: 'Number of items to return (offset-based, max 100)',
500        in: 'query',
501        required: false,
502        schema: new OA\Schema(type: 'integer', default: 25),
503    )]
504    #[OA\Parameter(
505        name: 'offset',
506        description: 'Starting offset (offset-based)',
507        in: 'query',
508        required: false,
509        schema: new OA\Schema(type: 'integer', default: 0),
510    )]
511    #[OA\Parameter(name: 'sort', description: 'Field to sort by', in: 'query', required: false, schema: new OA\Schema(
512        type: 'string',
513        default: 'id',
514        enum: ['id', 'title', 'author', 'updated', 'created'],
515    ))]
516    #[OA\Parameter(
517        name: 'order',
518        description: 'Sort direction',
519        in: 'query',
520        required: false,
521        schema: new OA\Schema(type: 'string', default: 'asc', enum: ['asc', 'desc']),
522    )]
523    #[OA\Response(response: 200, description: 'Returns paginated FAQs.', content: new OA\JsonContent(example: [
524        'success' => true,
525        'data' => [[
526            'id' => '1',
527            'lang' => 'en',
528            'solution_id' => '1000',
529            'revision_id' => '0',
530            'active' => 'yes',
531            'sticky' => '0',
532            'keywords' => '',
533            'title' => 'Is there life after death?',
534            'content' => 'Maybe!',
535            'author' => 'phpMyFAQ User',
536            'email' => 'user@example.org',
537            'comment' => 'y',
538            'updated' => '2009-10-10 17:54:00',
539            'dateStart' => '00000000000000',
540            'dateEnd' => '99991231235959',
541            'created' => '2008-09-03T21:30:17+02:00',
542            'notes' => '',
543        ]],
544        'meta' => [
545            'pagination' => [
546                'total' => 50,
547                'count' => 25,
548                'per_page' => 25,
549                'current_page' => 1,
550                'total_pages' => 2,
551                'links' => [
552                    'first' => '/api/v4.0/faqs?page=1&per_page=25',
553                    'last' => '/api/v4.0/faqs?page=2&per_page=25',
554                    'prev' => null,
555                    'next' => '/api/v4.0/faqs?page=2&per_page=25',
556                ],
557            ],
558            'sorting' => [
559                'field' => 'id',
560                'order' => 'asc',
561            ],
562        ],
563    ]))]
564    #[Route(path: 'v4.0/faqs', name: 'api.faqs.list', methods: ['GET'])]
565    public function list(?Request $request = null): JsonResponse
566    {
567        $request ??= Request::createFromGlobals();
568        return $this->withRequestLanguage($request, function () use ($request): JsonResponse {
569            [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
570
571            $this->faq->setUser($currentUser);
572            $this->faq->setGroups($currentGroups);
573
574            // Get pagination and sorting parameters
575            $pagination = $this->getPaginationRequest($request);
576            $sort = $this->getSortRequest(
577                $request,
578                allowedFields: ['id', 'title', 'author', 'updated', 'created'],
579                defaultField: 'id',
580                defaultOrder: 'asc',
581            );
582
583            $onlyActive = (bool) $this->configuration->get('api.onlyActiveFaqs');
584            $ignoreOrphanedFaqs = (bool) $this->configuration->get('api.ignoreOrphanedFaqs');
585
586            // Get all FAQs (this populates $this->faq->faqRecords)
587            $this->faq->getAllFaqs(
588                Faq::SORTING_TYPE_CATID_FAQID,
589                [
590                    'lang' => $this->configuration->getLanguage()->getLanguage(),
591                    'fcr.category_id' => $ignoreOrphanedFaqs ? 'IS NOT NULL' : null,
592                    'fd.active' => $onlyActive ? 'yes' : null,
593                ],
594                $sort->getOrderSql(),
595            );
596
597            $allFaqs = $this->faq->faqRecords;
598            $total = is_countable($allFaqs) ? count($allFaqs) : 0;
599
600            if ($sort->getField() && $sort->getField() !== 'id') {
601                usort($allFaqs, static function (array $a, array $b) use ($sort): int {
602                    $field = (string) $sort->getField();
603                    $aVal = (string) ($a[$field] ?? '');
604                    $bVal = (string) ($b[$field] ?? '');
605                    $result = $aVal <=> $bVal;
606                    return $sort->getOrderSql() === 'DESC' ? -$result : $result;
607                });
608            }
609
610            $result = array_slice($allFaqs, $pagination->offset, $pagination->limit);
611
612            return $this->paginatedResponse(
613                $request,
614                data: array_values($result),
615                total: $total,
616                pagination: $pagination,
617                options: new PaginatedResponseOptions(sort: $sort),
618            );
619        });
620    }
621
622    /**
623     * @throws \phpMyFAQ\Core\Exception|\JsonException|Exception
624     */
625    #[OA\Post(path: '/api/v4.0/faq/create', operationId: 'createFaq', tags: ['Endpoints with Authentication'])]
626    #[OA\Header(
627        header: 'Accept-Language',
628        description: 'The language code for the login.',
629        schema: new OA\Schema(type: 'string'),
630    )]
631    #[OA\Header(
632        header: 'x-pmf-token',
633        description: 'phpMyFAQ client API Token, generated in admin backend',
634        schema: new OA\Schema(type: 'string'),
635    )]
636    #[OA\RequestBody(
637        description: 'The category ID is a required value, the category name is optional. If the category name is '
638        . 'present and the ID can be mapped, the category ID from the name will be used. If the category name '
639        . 'cannot be mapped, a 409 error is thrown.',
640        required: true,
641        content: new OA\MediaType(
642            mediaType: 'application/json',
643            schema: new OA\Schema(
644                required: [
645                    'language',
646                    'category-id',
647                    'category-name',
648                    'question',
649                    'answer',
650                    'keywords',
651                    'author',
652                    'email',
653                    'is-active',
654                    'is-sticky',
655                ],
656                properties: [
657                    new OA\Property(property: 'language', type: 'string'),
658                    new OA\Property(property: 'category-id', type: 'integer'),
659                    new OA\Property(property: 'category-name', type: 'string'),
660                    new OA\Property(property: 'question', type: 'string'),
661                    new OA\Property(property: 'answer', type: 'string'),
662                    new OA\Property(property: 'keywords', type: 'string'),
663                    new OA\Property(property: 'author', type: 'string'),
664                    new OA\Property(property: 'email', type: 'string'),
665                    new OA\Property(property: 'is-active', type: 'boolean'),
666                    new OA\Property(property: 'is-sticky', type: 'boolean'),
667                ],
668                type: 'object',
669            ),
670            example: '{
671                "language": "de",
672                "category-id": 1,
673                "category-name": "Queen Songs",
674                "question": "Is this the world we created?",
675                "answer": "What did we do it for, is this the world we invaded, against the law, so it seems in the '
676            . 'end, is this what we\'re all living for today",
677                "keywords": "phpMyFAQ, FAQ, Foo, Bar",
678                "author": "Freddie Mercury",
679                "email": "freddie.mercury@example.org",
680                "is-active": "true",
681                "is-sticky": "false"
682            }',
683        ),
684    )]
685    #[OA\Response(response: 201, description: 'If all posted data is correct.', content: new OA\JsonContent(example: [
686        'stored' => true,
687    ]))]
688    #[OA\Response(response: 400, description: "If something didn't worked out.", content: new OA\JsonContent(example: [
689        'stored' => false,
690        'error' => 'It is not allowed, that the question title contains a hash.',
691    ]))]
692    #[OA\Response(
693        response: 409,
694        description: 'If the parent category name cannot be mapped.',
695        content: new OA\JsonContent(example: [
696            'stored' => false,
697            'error' => 'The given category name was not found',
698        ]),
699    )]
700    #[OA\Response(response: 401, description: 'If the user is not authenticated.')]
701    #[Route(path: 'v4.0/faq/create', name: 'api.faq.create', methods: ['POST'])]
702    public function create(Request $request): JsonResponse
703    {
704        $this->hasValidToken();
705        $this->userHasPermission(PermissionType::FAQ_ADD);
706
707        [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
708
709        $data = $this->getJsonObject($request);
710
711        $currentLanguage = $this->configuration->getLanguage()->getLanguage();
712
713        $category = new Category($this->configuration, $currentGroups, withPermission: true);
714        $category->setUser($currentUser);
715        $category->setGroups($currentGroups);
716        $category->setLanguage($currentLanguage);
717
718        $this->faq->setUser($currentUser);
719        $this->faq->setGroups($currentGroups);
720
721        $languageCode = Filter::filterVar($data->language ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
722        $categoryId = Filter::filterVar($data->{'category-id'} ?? null, FILTER_VALIDATE_INT);
723        $categoryName = null;
724
725        if (property_exists($data, 'category-name') && $data->{'category-name'} !== null) {
726            $categoryName = Filter::filterVar($data->{'category-name'}, FILTER_SANITIZE_SPECIAL_CHARS);
727        }
728
729        $question = Filter::filterHtml($data->question ?? '', '');
730        $answer = Filter::filterHtml($data->answer ?? '', '');
731        $keywords = Filter::filterVar($data->keywords ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
732        $author = Filter::filterVar($data->author ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
733        $email = (string) Filter::filterVar($data->email ?? '', FILTER_SANITIZE_EMAIL, '');
734        $isActive = Filter::filterVar($data->{'is-active'} ?? null, FILTER_VALIDATE_BOOLEAN);
735        $isSticky = Filter::filterVar($data->{'is-sticky'} ?? null, FILTER_VALIDATE_BOOLEAN);
736
737        // Check if the category name can be mapped
738        if (!is_null($categoryName)) {
739            $categoryIdFound = $category->getCategoryIdFromName($categoryName);
740            if ($categoryIdFound === false) {
741                $result = [
742                    'stored' => false,
743                    'error' => 'The given category name was not found.',
744                ];
745
746                return $this->json($result, Response::HTTP_CONFLICT);
747            }
748
749            $categoryId = $categoryIdFound;
750        }
751
752        if ($this->faq->hasTitleAHash($question)) {
753            $result = [
754                'stored' => false,
755                'error' => 'It is not allowed, that the question title contains a hash.',
756            ];
757            return $this->json($result, Response::HTTP_BAD_REQUEST);
758        }
759
760        $categories = [$categoryId];
761        $isActive = !is_null($isActive);
762        $isSticky = !is_null($isSticky);
763
764        $faqData = new FaqEntity();
765        $faqData
766            ->setLanguage($languageCode)
767            ->setQuestion($question)
768            ->setAnswer($answer)
769            ->setKeywords($keywords)
770            ->setAuthor($author)
771            ->setEmail($email)
772            ->setActive($isActive)
773            ->setSticky($isSticky)
774            ->setComment(comment: false)
775            ->setNotes(notes: '');
776
777        $faqEntity = $this->faq->create($faqData);
778
779        if ($faqEntity->getId() === null) {
780            $result = [
781                'stored' => false,
782                'error' => 'Cannot add FAQ',
783            ];
784            return $this->json($result, Response::HTTP_BAD_REQUEST);
785        }
786
787        $this->faqMetaData
788            ->setFaqId((int) $faqEntity->getId())
789            ->setFaqLanguage($languageCode)
790            ->setCategories(array_map(intval(...), $categories))
791            ->save();
792
793        return $this->json(['stored' => true], Response::HTTP_CREATED);
794    }
795
796    /**
797     * @throws \phpMyFAQ\Core\Exception|\JsonException|Exception
798     */
799    #[OA\Put(
800        path: '/api/v4.0/faq/update',
801        operationId: 'updateFaq',
802        description: 'Used to update a FAQ in one existing category.',
803        tags: ['Endpoints with Authentication'],
804    )]
805    #[OA\Header(
806        header: 'Accept-Language',
807        description: 'The language code for the login.',
808        schema: new OA\Schema(type: 'string'),
809    )]
810    #[OA\Header(
811        header: 'x-pmf-token',
812        description: 'phpMyFAQ client API Token, generated in admin backend',
813        schema: new OA\Schema(type: 'string'),
814    )]
815    #[OA\RequestBody(required: true, content: new OA\MediaType(
816        mediaType: 'application/json',
817        schema: new OA\Schema(
818            required: [
819                'faq-id',
820                'language',
821                'category-id',
822                'question',
823                'answer',
824                'keywords',
825                'author',
826                'email',
827                'is-active',
828                'is-sticky',
829            ],
830            properties: [
831                new OA\Property(property: 'faq-id', type: 'integer'),
832                new OA\Property(property: 'language', type: 'string'),
833                new OA\Property(property: 'category-id', type: 'integer'),
834                new OA\Property(property: 'question', type: 'string'),
835                new OA\Property(property: 'answer', type: 'string'),
836                new OA\Property(property: 'keywords', type: 'string'),
837                new OA\Property(property: 'author', type: 'string'),
838                new OA\Property(property: 'email', type: 'string'),
839                new OA\Property(property: 'is-active', type: 'boolean'),
840                new OA\Property(property: 'is-sticky', type: 'boolean'),
841            ],
842            type: 'object',
843        ),
844        example: '{
845                "faq-id": 1,
846                "language": "de",
847                "category-id": 1,
848                "question": "Is this the world we updated?",
849                "answer": "What did we do it for, is this the world we invaded, against the law, so it seems in the " .
850                    "end, is this what we\'re all living for today",
851                "keywords": "phpMyFAQ, FAQ, Foo, Bar",
852                "author": "Freddie Mercury",
853                "email": "freddie.mercury@example.org",
854                "is-active": "true",
855                "is-sticky": "false"
856            }',
857    ))]
858    #[OA\Response(response: 200, description: 'If all posted data is correct.', content: new OA\JsonContent(example: [
859        'stored' => true,
860    ]))]
861    #[OA\Response(response: 400, description: "If something didn't worked out.", content: new OA\JsonContent(example: [
862        'stored' => false,
863        'error' => 'It is not allowed, that the question title contains a hash.',
864    ]))]
865    #[OA\Response(response: 401, description: 'If the user is not authenticated.')]
866    #[Route(path: 'v4.0/faq/update', name: 'api.faq.update', methods: ['PUT'])]
867    public function update(Request $request): JsonResponse
868    {
869        $this->hasValidToken();
870        $this->userHasPermission(PermissionType::FAQ_EDIT);
871
872        [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
873
874        $data = $this->getJsonObject($request);
875
876        $currentLanguage = $this->configuration->getLanguage()->getLanguage();
877
878        $category = new Category($this->configuration, $currentGroups, withPermission: true);
879        $category->setUser($currentUser);
880        $category->setGroups($currentGroups);
881        $category->setLanguage($currentLanguage);
882
883        $this->faq->setUser($currentUser);
884        $this->faq->setGroups($currentGroups);
885
886        $faqId = Filter::filterVar($data->{'faq-id'} ?? null, FILTER_VALIDATE_INT);
887        $languageCode = Filter::filterVar($data->language ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
888        $question = Filter::filterHtml($data->question ?? '', '');
889        $answer = Filter::filterHtml($data->answer ?? '', '');
890        $keywords = Filter::filterVar($data->keywords ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
891        $author = Filter::filterVar($data->author ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
892        $email = (string) Filter::filterVar($data->email ?? '', FILTER_SANITIZE_EMAIL, '');
893        $isActive = Filter::filterVar($data->{'is-active'} ?? null, FILTER_VALIDATE_BOOLEAN);
894        $isSticky = Filter::filterVar($data->{'is-sticky'} ?? null, FILTER_VALIDATE_BOOLEAN);
895
896        if ($faqId === null) {
897            $result = [
898                'stored' => false,
899                'error' => 'Cannot update FAQ',
900            ];
901            return $this->json($result, Response::HTTP_BAD_REQUEST);
902        }
903
904        if ($this->faq->hasTitleAHash($question)) {
905            $result = [
906                'stored' => false,
907                'error' => 'It is not allowed, that the question title contains a hash.',
908            ];
909            return $this->json($result, Response::HTTP_BAD_REQUEST);
910        }
911
912        $isActive = !is_null($isActive);
913        $isSticky = !is_null($isSticky);
914
915        $faqEntity = new FaqEntity();
916        $faqEntity
917            ->setId($faqId)
918            ->setRevisionId(revisionId: 0)
919            ->setLanguage($languageCode)
920            ->setQuestion($question)
921            ->setAnswer($answer)
922            ->setKeywords($keywords)
923            ->setAuthor($author)
924            ->setEmail($email)
925            ->setActive($isActive)
926            ->setSticky($isSticky)
927            ->setComment(comment: false)
928            ->setNotes(notes: '');
929
930        $this->faq->update($faqEntity);
931
932        return $this->json(['stored' => true], Response::HTTP_OK);
933    }
934
935    private function applyRequestLanguage(Request $request): void
936    {
937        $currentLanguage = $this->resolveRequestLanguage($request);
938        Language::$language = $currentLanguage;
939        $this->session->set(name: 'lang', value: $currentLanguage);
940        $this->configuration->setLanguage($this->language);
941    }
942
943    private function resolveRequestLanguage(Request $request): string
944    {
945        foreach ($request->getLanguages() as $language) {
946            if (Language::isASupportedLanguage(strtoupper($language))) {
947                return strtolower($language);
948            }
949
950            $shortLanguage = substr(string: $language, offset: 0, length: 2);
951            if (Language::isASupportedLanguage(strtoupper($shortLanguage))) {
952                return strtolower($shortLanguage);
953            }
954        }
955
956        return $this->configuration->getLanguage()->getLanguage();
957    }
958
959    /** @param callable(): JsonResponse $callback */
960    private function withRequestLanguage(Request $request, callable $callback): JsonResponse
961    {
962        $previousLanguage = $this->configuration->getLanguage()->getLanguage();
963        $previousStaticLanguage = Language::$language;
964
965        try {
966            $this->applyRequestLanguage($request);
967            return $callback();
968        } finally {
969            Language::$language = $previousStaticLanguage !== '' ? $previousStaticLanguage : $previousLanguage;
970            $this->session->set(name: 'lang', value: Language::$language);
971            $this->configuration->setLanguage($this->language);
972        }
973    }
974}

Inherited from phpMyFAQ\Controller\Api\AbstractApiController

52    protected function initializeFromContainer(): void
53    {
54        parent::initializeFromContainer();
55
56        if (!$this->isApiEnabled()) {
57            throw new UnauthorizedHttpException(challenge: 'API is not enabled');
58        }
59    }
70    protected function getPaginationRequest(
71        Request $request,
72        int $defaultPerPage = self::DEFAULT_PER_PAGE,
73        ?int $maxPerPage = null,
74    ): PaginationRequest {
75        $maxPerPage ??= self::MAX_PER_PAGE;
76
77        return PaginationRequest::fromRequest($request, $defaultPerPage, $maxPerPage);
78    }
90    protected function getSortRequest(
91        Request $request,
92        array $allowedFields,
93        ?string $defaultField = null,
94        string $defaultOrder = 'asc',
95    ): SortRequest {
96        return SortRequest::fromRequest($request, $allowedFields, $defaultField, $defaultOrder);
97    }
115    protected function getFilterRequest(Request $request, array $allowedFilters): FilterRequest
116    {
117        return FilterRequest::fromRequest($request, $allowedFilters);
118    }
130    protected function paginatedResponse(
131        Request $request,
132        array $data,
133        int $total,
134        PaginationRequest $pagination,
135        ?PaginatedResponseOptions $options = null,
136    ): JsonResponse {
137        $options ??= new PaginatedResponseOptions();
138
139        // Build base URL for pagination links
140        $baseUrl = $request->getPathInfo();
141        $queryString = $request->getQueryString();
142        if ($queryString !== null && $queryString !== '') {
143            $baseUrl .= '?' . $queryString;
144        }
145
146        // Generate pagination metadata
147        $paginationMetadata = new PaginationMetadata(
148            total: $total,
149            request: $pagination,
150            baseUrl: $baseUrl,
151            actualCount: count($data),
152        );
153
154        // Build response with envelope
155        $responseData = ApiResponse::success(
156            data: $data,
157            pagination: $paginationMetadata,
158            sort: $options->sort,
159            filters: $options->filters,
160        );
161
162        $response = new JsonResponse($responseData, $options->status);
163        $response->setPublic();
164        $response->setMaxAge(0);
165        $response->headers->addCacheControlDirective('must-revalidate');
166        $response->setVary(['Accept-Language'], false);
167        $response->setEtag($this->createResponseEtag($responseData));
168        $response->isNotModified($request);
169
170        return $response;
171    }
182    protected function apiResponse(array|object $data, int $status = Response::HTTP_OK): JsonResponse
183    {
184        $responseData = ApiResponse::success(data: $data);
185
186        return new JsonResponse($responseData, $status);
187    }
198    protected function errorResponse(
199        string $message,
200        string $code = 'ERROR',
201        int $status = Response::HTTP_BAD_REQUEST,
202        ?array $details = null,
203    ): JsonResponse {
204        $responseData = ApiResponse::error(message: $message, code: $code, details: $details);
205
206        return new JsonResponse($responseData, $status);
207    }
215    private function createResponseEtag(array $responseData): string
216    {
217        return hash('sha256', json_encode($responseData, JSON_THROW_ON_ERROR));
218    }

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    }