Lines 92.44% 208 / 225
Methods 78.78% 26 / 33
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 search 88.67% 47 / 53 0.00% 0 / 1 8.09
 popular 100.00% 4 / 4 100.00% 1 / 1 3
 [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
36final class SearchController extends AbstractApiController
37{
38    public function __construct(
39        private readonly Search $search,
40    ) {
41        parent::__construct();
42    }
43
44    /**
45     * @throws Exception
46     */
47    #[OA\Get(
48        path: '/api/v4.0/search',
49        operationId: 'getSearch',
50        description: 'Returns paginated search results.',
51        tags: ['Public Endpoints'],
52    )]
53    #[OA\Parameter(
54        name: 'q',
55        description: 'The search term',
56        in: 'query',
57        required: true,
58        schema: new OA\Schema(type: 'string'),
59    )]
60    #[OA\Parameter(
61        name: 'page',
62        description: 'Page number for pagination (page-based)',
63        in: 'query',
64        required: false,
65        schema: new OA\Schema(type: 'integer', default: 1),
66    )]
67    #[OA\Parameter(
68        name: 'per_page',
69        description: 'Items per page (page-based, max 100)',
70        in: 'query',
71        required: false,
72        schema: new OA\Schema(type: 'integer', default: 25),
73    )]
74    #[OA\Parameter(
75        name: 'limit',
76        description: 'Number of items to return (offset-based, max 100)',
77        in: 'query',
78        required: false,
79        schema: new OA\Schema(type: 'integer', default: 25),
80    )]
81    #[OA\Parameter(
82        name: 'offset',
83        description: 'Starting offset (offset-based)',
84        in: 'query',
85        required: false,
86        schema: new OA\Schema(type: 'integer', default: 0),
87    )]
88    #[OA\Parameter(
89        name: 'sort',
90        description: 'Field to sort by',
91        in: 'query',
92        required: false,
93        schema: new OA\Schema(type: 'string', default: 'id', enum: ['id', 'question', 'category_id']),
94    )]
95    #[OA\Parameter(
96        name: 'order',
97        description: 'Sort direction',
98        in: 'query',
99        required: false,
100        schema: new OA\Schema(type: 'string', default: 'asc', enum: ['asc', 'desc']),
101    )]
102    #[OA\Response(
103        response: 200,
104        description: 'Returns paginated search results.',
105        content: new OA\JsonContent(example: [
106            'success' => true,
107            'data' => [[
108                'id' => '1',
109                'lang' => 'en',
110                'category_id' => '15',
111                'question' => 'Why are you using phpMyFAQ?',
112                'answer' => 'Because it is cool!',
113                'link' => 'https://www.example.org/content/15/1/en/why-are-you-using-phpmyfaq.html',
114            ]],
115            'meta' => [
116                'pagination' => [
117                    'total' => 50,
118                    'count' => 25,
119                    'per_page' => 25,
120                    'current_page' => 1,
121                    'total_pages' => 2,
122                    'links' => [
123                        'first' => '/api/v4.0/search?q=test&page=1&per_page=25',
124                        'last' => '/api/v4.0/search?q=test&page=2&per_page=25',
125                        'prev' => null,
126                        'next' => '/api/v4.0/search?q=test&page=2&per_page=25',
127                    ],
128                ],
129                'sorting' => [
130                    'field' => 'id',
131                    'order' => 'asc',
132                ],
133            ],
134        ]),
135    )]
136    #[Route(path: 'v4.0/search', name: 'api.search', methods: ['GET'])]
137    public function search(Request $request): JsonResponse
138    {
139        $this->search->setCategory(new Category($this->configuration));
140
141        $faqPermission = new Permission($this->configuration);
142        $searchResultSet = new SearchResultSet($this->currentUser, $faqPermission, $this->configuration);
143
144        $searchString = Filter::filterVar($request->query->get(key: 'q'), FILTER_SANITIZE_SPECIAL_CHARS, '');
145        $searchResults = $this->search->search(searchTerm: $searchString, allLanguages: false);
146        $searchResultSet->reviewResultSet($searchResults);
147
148        // Get pagination and sorting parameters
149        $pagination = $this->getPaginationRequest($request);
150        $sort = $this->getSortRequest(
151            $request,
152            allowedFields: ['id', 'question', 'category_id'],
153            defaultField: 'id',
154            defaultOrder: 'asc',
155        );
156
157        if ($searchResultSet->getNumberOfResults() > 0) {
158            $allResults = [];
159            foreach ($searchResultSet->getResultSet() as $data) {
160                $data->answer = strip_tags((string) $data->answer);
161                $data->answer = Utils::makeShorterText(string: $data->answer, characters: 12);
162                $data->link = sprintf(
163                    '%sfaq/%d/%d/%s/%s.html',
164                    $this->configuration->getDefaultUrl(),
165                    (int) $data->category_id,
166                    (int) $data->id,
167                    (string) $data->lang,
168                    TitleSlugifier::slug((string) $data->question),
169                );
170                $allResults[] = $data;
171            }
172
173            $total = count($allResults);
174
175            // Apply sorting if needed
176            $sortField = $sort->getField();
177            if ($sortField !== null && $sortField !== '') {
178                usort($allResults, static function (object $a, object $b) use ($sort, $sortField): int {
179                    $aVal = $a->{$sortField} ?? '';
180                    $bVal = $b->{$sortField} ?? '';
181                    $result = is_numeric($aVal) && is_numeric($bVal)
182                        ? (float) $aVal <=> (float) $bVal
183                        : (string) $aVal <=> (string) $bVal;
184                    return $sort->getOrderSql() === 'DESC' ? -$result : $result;
185                });
186            }
187
188            // Apply pagination
189            $result = array_slice($allResults, $pagination->offset, $pagination->limit);
190
191            return $this->paginatedResponse(
192                $request,
193                data: array_values($result),
194                total: $total,
195                pagination: $pagination,
196                options: new PaginatedResponseOptions(sort: $sort),
197            );
198        }
199
200        return $this->paginatedResponse(
201            $request,
202            data: [],
203            total: 0,
204            pagination: $pagination,
205            options: new PaginatedResponseOptions(sort: $sort),
206        );
207    }
208
209    /**
210     * @throws Exception
211     */
212    #[OA\Get(path: '/api/v4.0/searches/popular', operationId: 'getPopularSearch', tags: ['Public Endpoints'])]
213    #[OA\Header(
214        header: 'Accept-Language',
215        description: 'The language code for the login.',
216        schema: new OA\Schema(type: 'string'),
217    )]
218    #[OA\Response(
219        response: 200,
220        description: 'Returns the popular search terms for the given language provided by "Accept-Language"',
221        content: new OA\JsonContent(example: [
222            [
223                'id' => 3,
224                'searchterm' => 'mac',
225                'number' => '18',
226                'lang' => 'en',
227            ],
228            [
229                'id' => 7,
230                'searchterm' => 'test',
231                'number' => 9,
232                'lang' => 'en',
233            ],
234        ]),
235    )]
236    #[OA\Response(
237        response: 404,
238        description: 'If the popular search returns no results.',
239        content: new OA\JsonContent(example: []),
240    )]
241    #[Route(path: 'v4.0/searches/popular', name: 'api.search.popular', methods: ['GET'])]
242    public function popular(): JsonResponse
243    {
244        $result = $this->search->getMostPopularSearches(numResults: 7, withLang: true);
245
246        if ((is_countable($result) ? count($result) : 0) === 0) {
247            return $this->json([], Response::HTTP_NOT_FOUND);
248        }
249
250        return $this->json($result, Response::HTTP_OK);
251    }
252}

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    }