Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.60% covered (success)
95.60%
87 / 91
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchController
95.60% covered (success)
95.60%
87 / 91
75.00% covered (warning)
75.00%
3 / 4
10
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tagsPaginated
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 tags
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 index
95.29% covered (success)
95.29%
81 / 85
0.00% covered (danger)
0.00%
0 / 1
7
1<?php
2
3/**
4 * Search Controller
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2002-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2002-09-16
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Frontend;
21
22use Exception;
23use League\CommonMark\Exception\CommonMarkException;
24use phpMyFAQ\Filter;
25use phpMyFAQ\Language\Plurals;
26use phpMyFAQ\Search\SearchService;
27use phpMyFAQ\Strings;
28use phpMyFAQ\Translation;
29use phpMyFAQ\Twig\Extensions\TagNameTwigExtension;
30use phpMyFAQ\User\UserSession;
31use Symfony\Component\HttpFoundation\RedirectResponse;
32use Symfony\Component\HttpFoundation\Request;
33use Symfony\Component\HttpFoundation\Response;
34use Symfony\Component\Routing\Attribute\Route;
35use Twig\Extension\AttributeExtension;
36use Twig\TwigFilter;
37
38final class SearchController extends AbstractFrontController
39{
40    public function __construct(
41        private readonly UserSession $faqSession,
42        private readonly Plurals $plurals,
43    ) {
44        parent::__construct();
45    }
46
47    /**
48     * Redirects tag URLs with pagination to search
49     *
50     * @throws Exception
51     */
52    #[Route(path: '/tags/{tagId}/{page}/{slug}.html', name: 'public.tags.paginated', methods: ['GET'])]
53    public function tagsPaginated(Request $request): RedirectResponse
54    {
55        $tagId = Filter::filterVar($request->attributes->get('tagId'), FILTER_VALIDATE_INT, 0);
56        $page = Filter::filterVar($request->attributes->get('page'), FILTER_VALIDATE_INT, 1);
57
58        return new RedirectResponse(sprintf('/search.html?tagging_id=%d&seite=%d', $tagId, $page));
59    }
60
61    /**
62     * Redirects tag URLs to search
63     *
64     * @throws Exception
65     */
66    #[Route(path: '/tags/{tagId}/{slug}.html', name: 'public.tags', methods: ['GET'])]
67    public function tags(Request $request): RedirectResponse
68    {
69        $tagId = Filter::filterVar($request->attributes->get('tagId'), FILTER_VALIDATE_INT, 0);
70
71        return new RedirectResponse(sprintf('/search.html?tagging_id=%d', $tagId));
72    }
73
74    /**
75     * Displays search results for fulltext or tag-based search.
76     *
77     * @throws Exception|CommonMarkException
78     */
79    #[Route(path: '/search.html', name: 'public.search', methods: ['GET'])]
80    public function index(Request $request): Response
81    {
82        // Get user input
83        $inputLanguage = Filter::filterVar($request->query->get('pmf-all-languages'), FILTER_SANITIZE_SPECIAL_CHARS);
84        $inputCategory = Filter::filterVar($request->query->get('pmf-search-category'), FILTER_VALIDATE_INT, '%');
85        $inputSearchTerm = Filter::filterVar($request->query->get('search'), FILTER_SANITIZE_SPECIAL_CHARS, '');
86        $inputSearchTerm = Strings::substr($inputSearchTerm, 0, 255);
87
88        $inputTag = Filter::filterVar($request->query->get('tagging_id'), FILTER_SANITIZE_SPECIAL_CHARS);
89
90        if (!is_null($inputTag)) {
91            $inputTag = str_replace(search: ' ', replace: '', subject: (string) $inputTag);
92            $inputTag = str_replace(search: ',,', replace: ',', subject: $inputTag);
93        }
94
95        $searchTerm = Filter::filterVar($request->attributes->get('search'), FILTER_SANITIZE_SPECIAL_CHARS, '');
96        $searchTerm = Strings::substr($searchTerm, 0, 255);
97
98        $page = Filter::filterVar($request->query->get('seite'), FILTER_VALIDATE_INT, 1);
99
100        // Determine search language scope
101        $allLanguages = $inputLanguage !== '';
102
103        // Merge search terms
104        if ($searchTerm !== '') {
105            $inputSearchTerm = $searchTerm;
106        }
107
108        // Track user session
109        $this->faqSession->setCurrentUser($this->currentUser);
110        $this->faqSession->userTracking('fulltext_search', 0);
111        $this->faqSession->userTracking('fulltext_search', $inputSearchTerm);
112
113        // Get current groups
114        $currentGroups = $this->currentUser->perm->getUserGroups($this->currentUser->getUserId());
115
116        // Initialize search service
117        $searchService = new SearchService($this->configuration, $this->currentUser, $currentGroups);
118
119        // Process search
120        $searchData = $searchService->processSearch(
121            $inputSearchTerm,
122            $inputTag ?? '',
123            $inputCategory,
124            $allLanguages,
125            $page,
126        );
127
128        // Check for solution ID redirect
129        if ($searchService->shouldRedirectToSolutionId(
130            $inputSearchTerm,
131            (int) ($searchData['numberOfSearchResults'] ?? 0),
132        )) {
133            $redirectResponse = new RedirectResponse($searchService->getSolutionIdRedirectUrl($inputSearchTerm));
134            $redirectResponse->send();
135            exit();
136        }
137
138        // Set up Twig extensions
139        $this->addExtension(new AttributeExtension(TagNameTwigExtension::class));
140        $this->addFilter(new TwigFilter('repeat', static fn(
141            $string,
142            $times,
143        ): string => str_repeat((string) $string, max(0, (int) $times))));
144
145        // Determine page header
146        $pageHeader = $searchData['tagSearch']
147            ? Translation::getString(key: 'msgTagSearch')
148            : Translation::getString(key: 'msgAdvancedSearch');
149
150        // Render template
151        return $this->render('search.twig', [
152            ...$this->getHeader($request),
153            'title' => sprintf('%s - %s', $pageHeader, $this->configuration->getTitle()),
154            'pageHeader' => $pageHeader,
155            'isTagSearch' => $searchData['tagSearch'],
156            'selectedCategory' => $searchData['selectedCategory'],
157            'categories' => $searchData['categories'],
158            'msgSearch' => Translation::get(key: 'msgSearch'),
159            'msgAdvancedSearch' => $searchData['tagSearch']
160                ? Translation::get(key: 'msgTagSearch')
161                : Translation::get(key: 'msgAdvancedSearch'),
162            'msgCurrentTags' => Translation::get(key: 'msg_tags'),
163            'numberOfSearchResults' => $searchData['numberOfSearchResults'],
164            'totalPages' => $searchData['totalPages'],
165            'msgPage' => Translation::get(key: 'msgPage'),
166            'currentPage' => $searchData['currentPage'],
167            'from' => Translation::get(key: 'msgVoteFrom'),
168            'msgSearchResults' => $this->plurals->get(
169                'plmsgSearchAmount',
170                (int) ($searchData['numberOfSearchResults'] ?? 0),
171            ),
172            'msgSearchResultsPagination' => $this->plurals->get(
173                'plmsgPagesTotal',
174                (int) ($searchData['totalPages'] ?? 0),
175            ),
176            'searchTerm' => $searchData['searchTerm'],
177            'searchTags' => $searchData['searchTags'],
178            'msgSearchWord' => Translation::get(key: 'msgSearchWord'),
179            'searchResults' => $searchData['searchResults'],
180            'formActionUrl' => './search.html',
181            'searchString' => $inputSearchTerm,
182            'searchOnAllLanguages' => Translation::get(key: 'msgSearchOnAllLanguages'),
183            'checkedAllLanguages' => $searchData['allLanguages'] ? ' checked' : '',
184            'selectCategories' => Translation::get(key: 'msgSelectCategories'),
185            'allCategories' => Translation::get(key: 'msgAllCategories'),
186            'noSearchResults' => Translation::get(key: 'msgErrorNoRecords'),
187            'pagination' => $searchData['pagination'],
188            'msgMostPopularSearches' => Translation::get(key: 'msgMostPopularSearches'),
189            'mostPopularSearches' => $searchData['mostPopularSearches'],
190            'relatedTagsHeader' => Translation::get(key: 'msgRelatedTags'),
191            'relatedTags' => $searchData['relatedTags'],
192            'msgTags' => Translation::get(key: 'msgPopularTags'),
193            'tagList' => $searchData['tagList'],
194        ]);
195    }
196}