1<?php
2
3/*
4 * This file is part of the TYPO3 CMS project.
5 *
6 * It is free software; you can redistribute it and/or modify it under
7 * the terms of the GNU General Public License, either version 2
8 * of the License, or any later version.
9 *
10 * For the full copyright and license information, please read the
11 * LICENSE.txt file that was distributed with this source code.
12 *
13 * The TYPO3 project - inspiring people to share!
14 */
15
16namespace TYPO3\CMS\IndexedSearch\Domain\Repository;
17
18use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
19use TYPO3\CMS\Core\Context\Context;
20use TYPO3\CMS\Core\Database\Connection;
21use TYPO3\CMS\Core\Database\ConnectionPool;
22use TYPO3\CMS\Core\Database\Query\QueryHelper;
23use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
24use TYPO3\CMS\Core\TimeTracker\TimeTracker;
25use TYPO3\CMS\Core\Utility\GeneralUtility;
26use TYPO3\CMS\Core\Utility\MathUtility;
27use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
28use TYPO3\CMS\IndexedSearch\Indexer;
29use TYPO3\CMS\IndexedSearch\Utility\IndexedSearchUtility;
30use TYPO3\CMS\IndexedSearch\Utility\LikeWildcard;
31
32/**
33 * Index search abstraction to search through the index
34 * @internal This class is a specific repository implementation and is not considered part of the Public TYPO3 API.
35 */
36class IndexSearchRepository
37{
38    /**
39     * External Parsers
40     *
41     * @var array
42     */
43    protected $externalParsers = [];
44
45    /**
46     * Frontend User Group List
47     *
48     * @var string
49     */
50    protected $frontendUserGroupList = '';
51
52    /**
53     * Sections
54     * formally known as $this->piVars['sections']
55     *
56     * @var string
57     */
58    protected $sections;
59
60    /**
61     * Search type
62     * formally known as $this->piVars['type']
63     *
64     * @var int
65     */
66    protected int $searchType = 0;
67
68    /**
69     * Language uid
70     * formally known as $this->piVars['lang']
71     *
72     * @var int
73     */
74    protected $languageUid;
75
76    /**
77     * Media type
78     * formally known as $this->piVars['media']
79     *
80     * @var int
81     */
82    protected $mediaType;
83
84    /**
85     * Sort order
86     * formally known as $this->piVars['sort_order']
87     *
88     * @var string
89     */
90    protected $sortOrder = '';
91
92    /**
93     * Descending sort order flag
94     * formally known as $this->piVars['desc']
95     *
96     * @var bool
97     */
98    protected $descendingSortOrderFlag;
99
100    /**
101     * Result page pointer
102     * formally known as $this->piVars['pointer']
103     *
104     * @var int
105     */
106    protected $resultpagePointer = 0;
107
108    /**
109     * Number of results
110     * formally known as $this->piVars['result']
111     *
112     * @var int
113     */
114    protected $numberOfResults = 10;
115
116    /**
117     * list of all root pages that will be used
118     * If this value is set to less than zero (eg. -1) searching will happen
119     * in ALL of the page tree with no regard to branches at all.
120     *
121     * @var string
122     */
123    protected $searchRootPageIdList = '';
124
125    /**
126     * formally known as $conf['search.']['searchSkipExtendToSubpagesChecking']
127     * enabled through settings.searchSkipExtendToSubpagesChecking
128     *
129     * @var bool
130     */
131    protected $joinPagesForQuery = false;
132
133    /**
134     * Select clauses for individual words, will be filled during the search
135     *
136     * @var array
137     */
138    protected $wSelClauses = [];
139
140    /**
141     * Flag for exact search count
142     * formally known as $conf['search.']['exactCount']
143     *
144     * Continue counting and checking of results even if we are sure
145     * they are not displayed in this request. This will slow down your
146     * page rendering, but it allows precise search result counters.
147     * enabled through settings.exactCount
148     *
149     * @var bool
150     */
151    protected $useExactCount = false;
152
153    /**
154     * Display forbidden records
155     * formally known as $this->conf['show.']['forbiddenRecords']
156     *
157     * enabled through settings.displayForbiddenRecords
158     *
159     * @var bool
160     */
161    protected $displayForbiddenRecords = false;
162
163    /**
164     * initialize all options that are necessary for the search
165     *
166     * @param array $settings the extbase plugin settings
167     * @param array $searchData the search data
168     * @param array $externalParsers
169     * @param string $searchRootPageIdList
170     */
171    public function initialize($settings, $searchData, $externalParsers, $searchRootPageIdList)
172    {
173        $this->externalParsers = $externalParsers;
174        $this->searchRootPageIdList = (string)$searchRootPageIdList;
175        $this->frontendUserGroupList = implode(',', GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('frontend.user', 'groupIds', [0, -1]));
176        // Should we use joinPagesForQuery instead of long lists of uids?
177        if ($settings['searchSkipExtendToSubpagesChecking'] ?? false) {
178            $this->joinPagesForQuery = true;
179        }
180        if ($settings['exactCount'] ?? false) {
181            $this->useExactCount = true;
182        }
183        if ($settings['displayForbiddenRecords'] ?? false) {
184            $this->displayForbiddenRecords = true;
185        }
186        $this->sections = (string)($searchData['sections'] ?? '');
187        $this->searchType = (int)($searchData['searchType'] ?? 0);
188        $this->languageUid = (int)($searchData['languageUid'] ?? 0);
189        $this->mediaType = $searchData['mediaType'] ?? 0;
190        $this->sortOrder = (string)($searchData['sortOrder'] ?? '');
191        $this->descendingSortOrderFlag = $searchData['desc'] ?? false;
192        $this->resultpagePointer = (int)($searchData['pointer'] ?? 0);
193        if (is_numeric($searchData['numberOfResults'] ?? null)) {
194            $this->numberOfResults = (int)$searchData['numberOfResults'];
195        }
196    }
197
198    /**
199     * Get search result rows / data from database. Returned as data in array.
200     *
201     * @param array $searchWords Search word array
202     * @param int $freeIndexUid Pointer to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content.
203     * @return bool|array FALSE if no result, otherwise an array with keys for first row, result rows and total number of results found.
204     */
205    public function doSearch($searchWords, $freeIndexUid = -1)
206    {
207        $useMysqlFulltext = (bool)GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('indexed_search', 'useMysqlFulltext');
208        // Getting SQL result pointer:
209        $this->getTimeTracker()->push('Searching result');
210        if ($hookObj = $this->hookRequest('getResultRows_SQLpointer')) {
211            $result = $hookObj->getResultRows_SQLpointer($searchWords, $freeIndexUid);
212        } elseif ($useMysqlFulltext) {
213            $result = $this->getResultRows_SQLpointerMysqlFulltext($searchWords, $freeIndexUid);
214        } else {
215            $result = $this->getResultRows_SQLpointer($searchWords, $freeIndexUid);
216        }
217        $this->getTimeTracker()->pull();
218        // Organize and process result:
219        if ($result) {
220            // Total search-result count
221            $count = $result->rowCount();
222            // The pointer is set to the result page that is currently being viewed
223            $pointer = MathUtility::forceIntegerInRange($this->resultpagePointer, 0, (int)floor($count / $this->numberOfResults));
224            // Initialize result accumulation variables:
225            $c = 0;
226            // Result pointer: Counts up the position in the current search-result
227            $grouping_phashes = [];
228            // Used to filter out duplicates.
229            $grouping_chashes = [];
230            // Used to filter out duplicates BASED ON cHash.
231            $firstRow = [];
232            // Will hold the first row in result - used to calculate relative hit-ratings.
233            $resultRows = [];
234            // Will hold the results rows for display.
235            // Now, traverse result and put the rows to be displayed into an array
236            // Each row should contain the fields from 'ISEC.*, IP.*' combined
237            // + artificial fields "show_resume" (bool) and "result_number" (counter)
238            while ($row = $result->fetchAssociative()) {
239                // Set first row
240                if (!$c) {
241                    $firstRow = $row;
242                }
243                // Tells whether we can link directly to a document
244                // or not (depends on possible right problems)
245                $row['show_resume'] = $this->checkResume($row);
246                $phashGr = !in_array($row['phash_grouping'], $grouping_phashes);
247                $chashGr = !in_array($row['contentHash'] . '.' . $row['data_page_id'], $grouping_chashes);
248                if ($phashGr && $chashGr) {
249                    // Only if the resume may be shown are we going to filter out duplicates...
250                    if ($row['show_resume'] || $this->displayForbiddenRecords) {
251                        // Only on documents which are not multiple pages documents
252                        if (!$this->multiplePagesType($row['item_type'] ?? '')) {
253                            $grouping_phashes[] = $row['phash_grouping'];
254                        }
255                        $grouping_chashes[] = $row['contentHash'] . '.' . $row['data_page_id'];
256                        // Increase the result pointer
257                        $c++;
258                        // All rows for display is put into resultRows[]
259                        if ($c > $pointer * $this->numberOfResults && $c <= $pointer * $this->numberOfResults + $this->numberOfResults) {
260                            $row['result_number'] = $c;
261                            $resultRows[] = $row;
262                            // This may lead to a problem: If the result check is not stopped here, the search will take longer.
263                            // However the result counter will not filter out grouped cHashes/pHashes that were not processed yet.
264                            // You can change this behavior using the "settings.exactCount" property (see above).
265                            if (!$this->useExactCount && $c + 1 > ($pointer + 1) * $this->numberOfResults) {
266                                break;
267                            }
268                        }
269                    } else {
270                        // Skip this row if the user cannot
271                        // view it (missing permission)
272                        $count--;
273                    }
274                } else {
275                    // For each time a phash_grouping document is found
276                    // (which is thus not displayed) the search-result count is reduced,
277                    // so that it matches the number of rows displayed.
278                    $count--;
279                }
280            }
281
282            $result->free();
283
284            return [
285                'resultRows' => $resultRows,
286                'firstRow' => $firstRow,
287                'count' => $count,
288            ];
289        }
290        // No results found
291        return false;
292    }
293
294    /**
295     * Gets a SQL result pointer to traverse for the search records.
296     *
297     * @param array $searchWords Search words
298     * @param int $freeIndexUid Pointer to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content.
299     * @return \Doctrine\DBAL\Result|int|bool
300     */
301    protected function getResultRows_SQLpointer($searchWords, $freeIndexUid = -1)
302    {
303        // This SEARCHES for the searchwords in $searchWords AND returns a
304        // COMPLETE list of phash-integers of the matches.
305        $list = $this->getPhashList($searchWords);
306        // Perform SQL Search / collection of result rows array:
307        if ($list) {
308            // Do the search:
309            $this->getTimeTracker()->push('execFinalQuery');
310            $res = $this->execFinalQuery($list, $freeIndexUid);
311            $this->getTimeTracker()->pull();
312            return $res;
313        }
314        return false;
315    }
316
317    /**
318     * Gets a SQL result pointer to traverse for the search records.
319     *
320     * mysql fulltext specific version triggered by ext_conf_template setting 'useMysqlFulltext'
321     *
322     * @param array $searchWordsArray Search words
323     * @param int $freeIndexUid Pointer to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content.
324     * @return \Doctrine\DBAL\Result|int|bool DBAL result statement
325     */
326    protected function getResultRows_SQLpointerMysqlFulltext($searchWordsArray, $freeIndexUid = -1)
327    {
328        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('index_fulltext');
329        if (strpos($connection->getServerVersion(), 'MySQL') !== 0) {
330            throw new \RuntimeException(
331                'Extension indexed_search is configured to use mysql fulltext, but table \'index_fulltext\''
332                . ' is running on a different DBMS.',
333                1472585525
334            );
335        }
336        // Build the search string, detect which fulltext index to use, and decide whether boolean search is needed or not
337        $searchData = $this->getSearchString($searchWordsArray);
338        // Perform SQL Search / collection of result rows array:
339        $resource = false;
340        if ($searchData) {
341            /** @var TimeTracker $timeTracker */
342            $timeTracker = GeneralUtility::makeInstance(TimeTracker::class);
343            // Do the search:
344            $timeTracker->push('execFinalQuery');
345            $resource = $this->execFinalQuery_fulltext($searchData, $freeIndexUid);
346            $timeTracker->pull();
347        }
348        return $resource;
349    }
350
351    /**
352     * Returns a search string for use with MySQL FULLTEXT query
353     *
354     * mysql fulltext specific helper method
355     *
356     * @param array $searchWordArray Search word array
357     * @return array Search string
358     */
359    protected function getSearchString($searchWordArray)
360    {
361        // Change this to TRUE to force BOOLEAN SEARCH MODE (useful if fulltext index is still empty)
362        $searchBoolean = false;
363        $fulltextIndex = 'index_fulltext.fulltextdata';
364        // This holds the result if the search is natural (doesn't contain any boolean operators)
365        $naturalSearchString = '';
366        // This holds the result if the search is boolean (contains +/-/| operators)
367        $booleanSearchString = '';
368
369        $searchType = $this->getSearchType();
370
371        // Traverse searchwords and prefix them with corresponding operator
372        foreach ($searchWordArray as $searchWordData) {
373            // Making the query for a single search word based on the search-type
374            $searchWord = $searchWordData['sword'];
375            $wildcard = '';
376            if (str_contains($searchWord, ' ')) {
377                $searchType = 20;
378            }
379            switch ($searchType) {
380                case 1:
381                case 2:
382                case 3:
383                    // First part of word
384                    $wildcard = '*';
385                    // Part-of-word search requires boolean mode!
386                    $searchBoolean = true;
387                    break;
388                case 10:
389                    $indexerObj = GeneralUtility::makeInstance(Indexer::class);
390                    $searchWord = $indexerObj->metaphone($searchWord, $indexerObj->storeMetaphoneInfoAsWords);
391                    $fulltextIndex = 'index_fulltext.metaphonedata';
392                    break;
393                case 20:
394                    $searchBoolean = true;
395                    // Remove existing quotes and fix misplaced quotes.
396                    $searchWord = trim(str_replace('"', ' ', $searchWord));
397                    break;
398            }
399            // Perform search for word:
400            switch ($searchWordData['oper']) {
401                case 'AND NOT':
402                    $booleanSearchString .= ' -' . $searchWord . $wildcard;
403                    $searchBoolean = true;
404                    break;
405                case 'OR':
406                    $booleanSearchString .= ' ' . $searchWord . $wildcard;
407                    $searchBoolean = true;
408                    break;
409                default:
410                    $booleanSearchString .= ' +' . $searchWord . $wildcard;
411                    $naturalSearchString .= ' ' . $searchWord;
412            }
413        }
414        if ($searchType === 20) {
415            $searchString = '"' . trim($naturalSearchString) . '"';
416        } elseif ($searchBoolean) {
417            $searchString = trim($booleanSearchString);
418        } else {
419            $searchString = trim($naturalSearchString);
420        }
421        return [
422            'searchBoolean' => $searchBoolean,
423            'searchString' => $searchString,
424            'fulltextIndex' => $fulltextIndex,
425        ];
426    }
427
428    /**
429     * Execute final query, based on phash integer list. The main point is sorting the result in the right order.
430     *
431     * mysql fulltext specific helper method
432     *
433     * @param array $searchData Array with search string, boolean indicator, and fulltext index reference
434     * @param int $freeIndexUid Pointer to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content.
435     * @return \Doctrine\DBAL\Result
436     */
437    protected function execFinalQuery_fulltext($searchData, $freeIndexUid = -1)
438    {
439        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('index_fulltext');
440        $queryBuilder->getRestrictions()->removeAll();
441        $queryBuilder->select('index_fulltext.*', 'ISEC.*', 'IP.*')
442            ->from('index_fulltext')
443            ->join(
444                'index_fulltext',
445                'index_phash',
446                'IP',
447                $queryBuilder->expr()->eq('index_fulltext.phash', $queryBuilder->quoteIdentifier('IP.phash'))
448            )
449            ->join(
450                'IP',
451                'index_section',
452                'ISEC',
453                $queryBuilder->expr()->eq('IP.phash', $queryBuilder->quoteIdentifier('ISEC.phash'))
454            );
455
456        // Calling hook for alternative creation of page ID list
457        $searchRootPageIdList = $this->getSearchRootPageIdList();
458        if ($hookObj = $this->hookRequest('execFinalQuery_idList')) {
459            $pageWhere = $hookObj->execFinalQuery_idList('');
460            $queryBuilder->andWhere(QueryHelper::stripLogicalOperatorPrefix($pageWhere));
461        } elseif ($this->joinPagesForQuery) {
462            // Alternative to getting all page ids by ->getTreeList() where "excludeSubpages" is NOT respected.
463            $queryBuilder
464                ->join(
465                    'ISEC',
466                    'pages',
467                    'pages',
468                    $queryBuilder->expr()->eq('ISEC.page_id', $queryBuilder->quoteIdentifier('pages.uid'))
469                )
470                ->andWhere(
471                    $queryBuilder->expr()->eq(
472                        'pages.no_search',
473                        $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
474                    )
475                )
476                ->andWhere(
477                    $queryBuilder->expr()->lt(
478                        'pages.doktype',
479                        $queryBuilder->createNamedParameter(200, \PDO::PARAM_INT)
480                    )
481                );
482            $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
483        } elseif ($searchRootPageIdList[0] >= 0) {
484            // Collecting all pages IDs in which to search;
485            // filtering out ALL pages that are not accessible due to restriction containers. Does NOT look for "no_search" field!
486            $idList = [];
487            foreach ($searchRootPageIdList as $rootId) {
488                /** @var \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer $cObj */
489                $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
490                $idList[] = $cObj->getTreeList(-1 * $rootId, 9999);
491            }
492            $idList = GeneralUtility::intExplode(',', implode(',', $idList));
493            $queryBuilder->andWhere(
494                $queryBuilder->expr()->in(
495                    'ISEC.page_id',
496                    $queryBuilder->createNamedParameter($idList, Connection::PARAM_INT_ARRAY)
497                )
498            );
499        }
500
501        $searchBoolean = '';
502        if ($searchData['searchBoolean']) {
503            $searchBoolean = ' IN BOOLEAN MODE';
504        }
505        $queryBuilder->andWhere(
506            'MATCH (' . $queryBuilder->quoteIdentifier($searchData['fulltextIndex']) . ')'
507            . ' AGAINST (' . $queryBuilder->createNamedParameter($searchData['searchString'])
508            . $searchBoolean
509            . ')'
510        );
511
512        $queryBuilder->andWhere(
513            QueryHelper::stripLogicalOperatorPrefix($this->mediaTypeWhere()),
514            QueryHelper::stripLogicalOperatorPrefix($this->languageWhere()),
515            QueryHelper::stripLogicalOperatorPrefix($this->freeIndexUidWhere($freeIndexUid)),
516            QueryHelper::stripLogicalOperatorPrefix($this->sectionTableWhere())
517        );
518
519        $queryBuilder->groupBy(
520            'IP.phash',
521            'ISEC.phash',
522            'ISEC.phash_t3',
523            'ISEC.rl0',
524            'ISEC.rl1',
525            'ISEC.rl2',
526            'ISEC.page_id',
527            'ISEC.uniqid',
528            'IP.phash_grouping',
529            'IP.data_filename',
530            'IP.data_page_id',
531            'IP.data_page_type',
532            'IP.data_page_mp',
533            'IP.gr_list',
534            'IP.item_type',
535            'IP.item_title',
536            'IP.item_description',
537            'IP.item_mtime',
538            'IP.tstamp',
539            'IP.item_size',
540            'IP.contentHash',
541            'IP.crdate',
542            'IP.parsetime',
543            'IP.sys_language_uid',
544            'IP.item_crdate',
545            'IP.externalUrl',
546            'IP.recordUid',
547            'IP.freeIndexUid',
548            'IP.freeIndexSetId'
549        );
550
551        return $queryBuilder->executeQuery();
552    }
553
554    /***********************************
555     *
556     *	Helper functions on searching (SQL)
557     *
558     ***********************************/
559    /**
560     * Returns a COMPLETE list of phash-integers matching the search-result composed of the search-words in the $searchWords array.
561     * The list of phash integers are unsorted and should be used for subsequent selection of index_phash records for display of the result.
562     *
563     * @param array $searchWords Search word array
564     * @return string List of integers
565     */
566    protected function getPhashList($searchWords)
567    {
568        // Initialize variables:
569        $c = 0;
570        // This array accumulates the phash-values
571        $totalHashList = [];
572        $this->wSelClauses = [];
573        // Traverse searchwords; for each, select all phash integers and merge/diff/intersect them with previous word (based on operator)
574        foreach ($searchWords as $v) {
575            // Making the query for a single search word based on the search-type
576            $sWord = $v['sword'] ?? '';
577            $theType = $this->searchType;
578            // If there are spaces in the search-word, make a full text search instead.
579            if (str_contains($sWord, ' ')) {
580                $theType = 20;
581            }
582            $this->getTimeTracker()->push('SearchWord "' . $sWord . '" - $theType=' . (string)$theType);
583            // Perform search for word:
584            switch ($theType) {
585                case 1:
586                    // Part of word
587                    $res = $this->searchWord($sWord, LikeWildcard::BOTH);
588                    break;
589                case 2:
590                    // First part of word
591                    $res = $this->searchWord($sWord, LikeWildcard::RIGHT);
592                    break;
593                case 3:
594                    // Last part of word
595                    $res = $this->searchWord($sWord, LikeWildcard::LEFT);
596                    break;
597                case 10:
598                    // Sounds like
599                    $indexerObj = GeneralUtility::makeInstance(Indexer::class);
600                    // Perform metaphone search
601                    $storeMetaphoneInfoAsWords = !$this->isTableUsed('index_words');
602                    $res = $this->searchMetaphone($indexerObj->metaphone($sWord, $storeMetaphoneInfoAsWords));
603                    break;
604                case 20:
605                    // Sentence
606                    $res = $this->searchSentence($sWord);
607                    // If there is a fulltext search for a sentence there is
608                    // a likeliness that sorting cannot be done by the rankings
609                    // from the rel-table (because no relations will exist for the
610                    // sentence in the word-table). So therefore mtime is used instead.
611                    // It is not required, but otherwise some hits may be left out.
612                    $this->sortOrder = 'mtime';
613                    break;
614                default:
615                    // Distinct word
616                    $res = $this->searchDistinct($sWord);
617            }
618            // If there was a query to do, then select all phash-integers which resulted from this.
619            if ($res) {
620                // Get phash list by searching for it:
621                $phashList = [];
622                while ($row = $res->fetchAssociative()) {
623                    $phashList[] = $row['phash'];
624                }
625                // Here the phash list are merged with the existing result based on whether we are dealing with OR, NOT or AND operations.
626                if ($c) {
627                    switch ($v['oper']) {
628                        case 'OR':
629                            $totalHashList = array_unique(array_merge($phashList, $totalHashList));
630                            break;
631                        case 'AND NOT':
632                            $totalHashList = array_diff($totalHashList, $phashList);
633                            break;
634                        default:
635                            // AND...
636                            $totalHashList = array_intersect($totalHashList, $phashList);
637                    }
638                } else {
639                    // First search
640                    $totalHashList = $phashList;
641                }
642            }
643            $this->getTimeTracker()->pull();
644            $c++;
645        }
646        return implode(',', $totalHashList);
647    }
648
649    /**
650     * Returns a query which selects the search-word from the word/rel tables.
651     *
652     * @param string $wordSel WHERE clause selecting the word from phash
653     * @param string $additionalWhereClause Additional AND clause in the end of the query.
654     * @return \Doctrine\DBAL\Result|int
655     */
656    protected function execPHashListQuery($wordSel, $additionalWhereClause = '')
657    {
658        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('index_words');
659        $queryBuilder->select('IR.phash')
660            ->from('index_words', 'IW')
661            ->from('index_rel', 'IR')
662            ->from('index_section', 'ISEC')
663            ->where(
664                QueryHelper::stripLogicalOperatorPrefix($wordSel),
665                $queryBuilder->expr()->eq('IW.wid', $queryBuilder->quoteIdentifier('IR.wid')),
666                $queryBuilder->expr()->eq('ISEC.phash', $queryBuilder->quoteIdentifier('IR.phash')),
667                QueryHelper::stripLogicalOperatorPrefix($this->sectionTableWhere()),
668                QueryHelper::stripLogicalOperatorPrefix($additionalWhereClause)
669            )
670            ->groupBy('IR.phash');
671
672        return $queryBuilder->executeQuery();
673    }
674
675    /**
676     * Search for a word
677     *
678     * @param string $sWord the search word
679     * @param int $wildcard Bit-field of Utility\LikeWildcard
680     * @return \Doctrine\DBAL\Result
681     */
682    protected function searchWord($sWord, $wildcard)
683    {
684        $likeWildcard = LikeWildcard::cast($wildcard);
685        $wSel = $likeWildcard->getLikeQueryPart(
686            'index_words',
687            'IW.baseword',
688            $sWord
689        );
690        $this->wSelClauses[] = $wSel;
691        return $this->execPHashListQuery($wSel, ' AND is_stopword=0');
692    }
693
694    /**
695     * Search for one distinct word
696     *
697     * @param string $sWord the search word
698     * @return \Doctrine\DBAL\Result
699     */
700    protected function searchDistinct($sWord)
701    {
702        $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
703            ->getQueryBuilderForTable('index_words')
704            ->expr();
705        $wSel = $expressionBuilder->eq('IW.wid', $this->md5inthash($sWord));
706        $this->wSelClauses[] = $wSel;
707        return $this->execPHashListQuery($wSel, $expressionBuilder->eq('is_stopword', 0));
708    }
709
710    /**
711     * Search for a sentence
712     *
713     * @param string $sWord the search word
714     * @return \Doctrine\DBAL\Result
715     */
716    protected function searchSentence($sWord)
717    {
718        $this->wSelClauses[] = '1=1';
719        $likeWildcard = LikeWildcard::cast(LikeWildcard::BOTH);
720        $likePart = $likeWildcard->getLikeQueryPart(
721            'index_fulltext',
722            'IFT.fulltextdata',
723            $sWord
724        );
725
726        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('index_section');
727        return $queryBuilder->select('ISEC.phash')
728            ->from('index_section', 'ISEC')
729            ->from('index_fulltext', 'IFT')
730            ->where(
731                QueryHelper::stripLogicalOperatorPrefix($likePart),
732                $queryBuilder->expr()->eq('ISEC.phash', $queryBuilder->quoteIdentifier('IFT.phash')),
733                QueryHelper::stripLogicalOperatorPrefix($this->sectionTableWhere())
734            )
735            ->groupBy('ISEC.phash')
736            ->executeQuery();
737    }
738
739    /**
740     * Search for a metaphone word
741     *
742     * @param string $sWord the search word
743     * @return \Doctrine\DBAL\Result
744     */
745    protected function searchMetaphone($sWord)
746    {
747        $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
748            ->getQueryBuilderForTable('index_words')
749            ->expr();
750        $wSel = $expressionBuilder->eq('IW.metaphone', $expressionBuilder->literal($sWord));
751        $this->wSelClauses[] = $wSel;
752        return $this->execPHashListQuery($wSel, $expressionBuilder->eq('is_stopword', 0));
753    }
754
755    /**
756     * Returns AND statement for selection of section in database. (rootlevel 0-2 + page_id)
757     *
758     * @return string AND clause for selection of section in database.
759     */
760    public function sectionTableWhere()
761    {
762        $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
763            ->getQueryBuilderForTable('index_section')
764            ->expr();
765
766        $whereClause = $expressionBuilder->andX();
767        $match = false;
768        if (!($this->searchRootPageIdList < 0)) {
769            $whereClause->add(
770                $expressionBuilder->in('ISEC.rl0', GeneralUtility::intExplode(',', $this->searchRootPageIdList, true))
771            );
772        }
773        if (strpos($this->sections, 'rl1_') === 0) {
774            $whereClause->add(
775                $expressionBuilder->in('ISEC.rl1', GeneralUtility::intExplode(',', substr($this->sections, 4)))
776            );
777            $match = true;
778        } elseif (strpos($this->sections, 'rl2_') === 0) {
779            $whereClause->add(
780                $expressionBuilder->in('ISEC.rl2', GeneralUtility::intExplode(',', substr($this->sections, 4)))
781            );
782            $match = true;
783        } else {
784            // Traversing user configured fields to see if any of those are used to limit search to a section:
785            foreach ($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['addRootLineFields'] ?? [] as $fieldName => $rootLineLevel) {
786                if (strpos($this->sections, $fieldName . '_') === 0) {
787                    $whereClause->add(
788                        $expressionBuilder->in(
789                            'ISEC.' . $fieldName,
790                            GeneralUtility::intExplode(',', substr($this->sections, strlen($fieldName) + 1))
791                        )
792                    );
793                    $match = true;
794                    break;
795                }
796            }
797        }
798        // If no match above, test the static types:
799        if (!$match) {
800            switch ((string)$this->sections) {
801                case '-1':
802                    $whereClause->add(
803                        $expressionBuilder->eq('ISEC.page_id', (int)$this->getTypoScriptFrontendController()->id)
804                    );
805                    break;
806                case '-2':
807                    $whereClause->add($expressionBuilder->eq('ISEC.rl2', 0));
808                    break;
809                case '-3':
810                    $whereClause->add($expressionBuilder->gt('ISEC.rl2', 0));
811                    break;
812            }
813        }
814
815        return $whereClause->count() ? ' AND ' . $whereClause : '';
816    }
817
818    /**
819     * Returns AND statement for selection of media type
820     *
821     * @return string AND statement for selection of media type
822     */
823    public function mediaTypeWhere()
824    {
825        $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
826            ->getQueryBuilderForTable('index_phash')
827            ->expr();
828        switch ($this->mediaType) {
829            case '0':
830                // '0' => 'only TYPO3 pages',
831                $whereClause = $expressionBuilder->eq('IP.item_type', $expressionBuilder->literal('0'));
832                break;
833            case '-2':
834                // All external documents
835                $whereClause = $expressionBuilder->neq('IP.item_type', $expressionBuilder->literal('0'));
836                break;
837            case false:
838                // Intentional fall-through
839            case '-1':
840                // All content
841                $whereClause = '';
842                break;
843            default:
844                $whereClause = $expressionBuilder->eq('IP.item_type', $expressionBuilder->literal($this->mediaType));
845        }
846        return $whereClause ? ' AND ' . $whereClause : '';
847    }
848
849    /**
850     * Returns AND statement for selection of language
851     *
852     * @return string AND statement for selection of language
853     */
854    public function languageWhere()
855    {
856        // -1 is the same as ALL language.
857        if ($this->languageUid < 0) {
858            return '';
859        }
860
861        $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
862            ->getQueryBuilderForTable('index_phash')
863            ->expr();
864
865        return ' AND ' . $expressionBuilder->eq('IP.sys_language_uid', $this->languageUid);
866    }
867
868    /**
869     * Where-clause for free index-uid value.
870     *
871     * @param int $freeIndexUid Free Index UID value to limit search to.
872     * @return string WHERE SQL clause part.
873     */
874    public function freeIndexUidWhere($freeIndexUid)
875    {
876        $freeIndexUid = (int)$freeIndexUid;
877        if ($freeIndexUid < 0) {
878            return '';
879        }
880        // First, look if the freeIndexUid is a meta configuration:
881        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
882            ->getQueryBuilderForTable('index_config');
883        $indexCfgRec = $queryBuilder->select('indexcfgs')
884            ->from('index_config')
885            ->where(
886                $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter(5, \PDO::PARAM_INT)),
887                $queryBuilder->expr()->eq(
888                    'uid',
889                    $queryBuilder->createNamedParameter($freeIndexUid, \PDO::PARAM_INT)
890                )
891            )
892            ->executeQuery()
893            ->fetchAssociative();
894
895        if (is_array($indexCfgRec)) {
896            $refs = GeneralUtility::trimExplode(',', $indexCfgRec['indexcfgs']);
897            // Default value to protect against empty array.
898            $list = [-99];
899            foreach ($refs as $ref) {
900                [$table, $uid] = GeneralUtility::revExplode('_', $ref, 2);
901                $uid = (int)$uid;
902                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
903                    ->getQueryBuilderForTable('index_config');
904                $queryBuilder->select('uid')
905                    ->from('index_config');
906                switch ($table) {
907                    case 'index_config':
908                        $idxRec = $queryBuilder
909                            ->where(
910                                $queryBuilder->expr()->eq(
911                                    'uid',
912                                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
913                                )
914                            )
915                            ->executeQuery()
916                            ->fetchAssociative();
917                        if ($idxRec) {
918                            $list[] = $uid;
919                        }
920                        break;
921                    case 'pages':
922                        $indexCfgRecordsFromPid = $queryBuilder
923                            ->where(
924                                $queryBuilder->expr()->eq(
925                                    'pid',
926                                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
927                                )
928                            )
929                            ->executeQuery();
930                        while ($idxRec = $indexCfgRecordsFromPid->fetchAssociative()) {
931                            $list[] = $idxRec['uid'];
932                        }
933                        break;
934                }
935            }
936            $list = array_unique($list);
937        } else {
938            $list = [$freeIndexUid];
939        }
940
941        $expressionBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
942            ->getQueryBuilderForTable('index_phash')
943            ->expr();
944        return ' AND ' . $expressionBuilder->in('IP.freeIndexUid', array_map('intval', $list));
945    }
946
947    /**
948     * Execute final query, based on phash integer list. The main point is sorting the result in the right order.
949     *
950     * @param string $list List of phash integers which match the search.
951     * @param int $freeIndexUid Pointer to which indexing configuration you want to search in. -1 means no filtering. 0 means only regular indexed content.
952     * @return \Doctrine\DBAL\Result
953     */
954    protected function execFinalQuery($list, $freeIndexUid = -1)
955    {
956        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('index_words');
957        $queryBuilder->select('ISEC.*', 'IP.*')
958            ->from('index_phash', 'IP')
959            ->from('index_section', 'ISEC')
960            ->where(
961                $queryBuilder->expr()->in(
962                    'IP.phash',
963                    $queryBuilder->createNamedParameter(
964                        GeneralUtility::intExplode(',', $list, true),
965                        Connection::PARAM_INT_ARRAY
966                    )
967                ),
968                QueryHelper::stripLogicalOperatorPrefix($this->mediaTypeWhere()),
969                QueryHelper::stripLogicalOperatorPrefix($this->languageWhere()),
970                QueryHelper::stripLogicalOperatorPrefix($this->freeIndexUidWhere($freeIndexUid)),
971                $queryBuilder->expr()->eq('ISEC.phash', $queryBuilder->quoteIdentifier('IP.phash'))
972            )
973            ->groupBy(
974                'IP.phash',
975                'ISEC.phash',
976                'ISEC.phash_t3',
977                'ISEC.rl0',
978                'ISEC.rl1',
979                'ISEC.rl2',
980                'ISEC.page_id',
981                'ISEC.uniqid',
982                'IP.phash_grouping',
983                'IP.data_filename',
984                'IP.data_page_id',
985                'IP.data_page_type',
986                'IP.data_page_mp',
987                'IP.gr_list',
988                'IP.item_type',
989                'IP.item_title',
990                'IP.item_description',
991                'IP.item_mtime',
992                'IP.tstamp',
993                'IP.item_size',
994                'IP.contentHash',
995                'IP.crdate',
996                'IP.parsetime',
997                'IP.sys_language_uid',
998                'IP.item_crdate',
999                'IP.externalUrl',
1000                'IP.recordUid',
1001                'IP.freeIndexUid',
1002                'IP.freeIndexSetId',
1003                'IP.static_page_arguments'
1004            );
1005
1006        // Setting up methods of filtering results
1007        // based on page types, access, etc.
1008        if ($hookObj = $this->hookRequest('execFinalQuery_idList')) {
1009            // Calling hook for alternative creation of page ID list
1010            $hookWhere = QueryHelper::stripLogicalOperatorPrefix($hookObj->execFinalQuery_idList($list));
1011            if (!empty($hookWhere)) {
1012                $queryBuilder->andWhere($hookWhere);
1013            }
1014        } elseif ($this->joinPagesForQuery) {
1015            // Alternative to getting all page ids by ->getTreeList() where
1016            // "excludeSubpages" is NOT respected.
1017            $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
1018            $queryBuilder->from('pages');
1019            $queryBuilder->andWhere(
1020                $queryBuilder->expr()->eq('pages.uid', $queryBuilder->quoteIdentifier('ISEC.page_id')),
1021                $queryBuilder->expr()->eq(
1022                    'pages.no_search',
1023                    $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
1024                ),
1025                $queryBuilder->expr()->lt(
1026                    'pages.doktype',
1027                    $queryBuilder->createNamedParameter(200, \PDO::PARAM_INT)
1028                )
1029            );
1030        } elseif ($this->searchRootPageIdList >= 0) {
1031            // Collecting all pages IDs in which to search;
1032            // filtering out ALL pages that are not accessible due to restriction containers.
1033            // Does NOT look for "no_search" field!
1034            $siteIdNumbers = GeneralUtility::intExplode(',', $this->searchRootPageIdList);
1035            $pageIdList = [];
1036            foreach ($siteIdNumbers as $rootId) {
1037                $pageIdList[] = $this->getTypoScriptFrontendController()->cObj->getTreeList(-1 * $rootId, 9999);
1038            }
1039            $queryBuilder->andWhere(
1040                $queryBuilder->expr()->in(
1041                    'ISEC.page_id',
1042                    $queryBuilder->createNamedParameter(
1043                        array_unique(GeneralUtility::intExplode(',', implode(',', $pageIdList), true)),
1044                        Connection::PARAM_INT_ARRAY
1045                    )
1046                )
1047            );
1048        }
1049        // otherwise select all / disable everything
1050        // If any of the ranking sortings are selected, we must make a
1051        // join with the word/rel-table again, because we need to
1052        // calculate ranking based on all search-words found.
1053        if (strpos($this->sortOrder, 'rank_') === 0) {
1054            $queryBuilder
1055                ->from('index_words', 'IW')
1056                ->from('index_rel', 'IR')
1057                ->andWhere(
1058                    $queryBuilder->expr()->eq('IW.wid', $queryBuilder->quoteIdentifier('IR.wid')),
1059                    $queryBuilder->expr()->eq('ISEC.phash', $queryBuilder->quoteIdentifier('IR.phash'))
1060                );
1061            switch ($this->sortOrder) {
1062                case 'rank_flag':
1063                    // This gives priority to word-position (max-value) so that words in title, keywords, description counts more than in content.
1064                    // The ordering is refined with the frequency sum as well.
1065                    $queryBuilder
1066                        ->addSelectLiteral(
1067                            $queryBuilder->expr()->max('IR.flags', 'order_val1'),
1068                            $queryBuilder->expr()->sum('IR.freq', 'order_val2')
1069                        )
1070                        ->orderBy('order_val1', $this->getDescendingSortOrderFlag())
1071                        ->addOrderBy('order_val2', $this->getDescendingSortOrderFlag());
1072                    break;
1073                case 'rank_first':
1074                    // Results in average position of search words on page.
1075                    // Must be inversely sorted (low numbers are closer to top)
1076                    $queryBuilder
1077                        ->addSelectLiteral($queryBuilder->expr()->avg('IR.first', 'order_val'))
1078                        ->orderBy('order_val', $this->getDescendingSortOrderFlag(true));
1079                    break;
1080                case 'rank_count':
1081                    // Number of words found
1082                    $queryBuilder
1083                        ->addSelectLiteral($queryBuilder->expr()->sum('IR.count', 'order_val'))
1084                        ->orderBy('order_val', $this->getDescendingSortOrderFlag());
1085                    break;
1086                default:
1087                    // Frequency sum. I'm not sure if this is the best way to do
1088                    // it (make a sum...). Or should it be the average?
1089                    $queryBuilder
1090                        ->addSelectLiteral($queryBuilder->expr()->sum('IR.freq', 'order_val'))
1091                        ->orderBy('order_val', $this->getDescendingSortOrderFlag());
1092            }
1093
1094            if (!empty($this->wSelClauses)) {
1095                // So, words are combined in an OR statement
1096                // (no "sentence search" should be done here - may deselect results)
1097                $wordSel = $queryBuilder->expr()->orX();
1098                foreach ($this->wSelClauses as $wSelClause) {
1099                    $wordSel->add(QueryHelper::stripLogicalOperatorPrefix($wSelClause));
1100                }
1101                $queryBuilder->andWhere($wordSel);
1102            }
1103        } else {
1104            // Otherwise, if sorting are done with the pages table or other fields,
1105            // there is no need for joining with the rel/word tables:
1106            switch ($this->sortOrder) {
1107                case 'title':
1108                    $queryBuilder->orderBy('IP.item_title', $this->getDescendingSortOrderFlag());
1109                    break;
1110                case 'crdate':
1111                    $queryBuilder->orderBy('IP.item_crdate', $this->getDescendingSortOrderFlag());
1112                    break;
1113                case 'mtime':
1114                    $queryBuilder->orderBy('IP.item_mtime', $this->getDescendingSortOrderFlag());
1115                    break;
1116            }
1117        }
1118
1119        return $queryBuilder->executeQuery();
1120    }
1121
1122    /**
1123     * Checking if the resume can be shown for the search result
1124     * (depending on whether the rights are OK)
1125     * ? Should it also check for gr_list "0,-1"?
1126     *
1127     * @param array $row Result row array.
1128     * @return bool Returns TRUE if resume can safely be shown
1129     */
1130    protected function checkResume($row)
1131    {
1132        // If the record is indexed by an indexing configuration, just show it.
1133        // At least this is needed for external URLs and files.
1134        // For records we might need to extend this - for instance block display if record is access restricted.
1135        if ($row['freeIndexUid']) {
1136            return true;
1137        }
1138        // Evaluate regularly indexed pages based on item_type:
1139        // External media:
1140        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('index_grlist');
1141        if ($row['item_type']) {
1142            // For external media we will check the access of the parent page on which the media was linked from.
1143            // "phash_t3" is the phash of the parent TYPO3 page row which initiated the indexing of the documents
1144            // in this section. So, selecting for the grlist records belonging to the parent phash-row where the
1145            // current users gr_list exists will help us to know. If this is NOT found, there is still a theoretical
1146            // possibility that another user accessible page would display a link, so maybe the resume of such a
1147            // document here may be unjustified hidden. But better safe than sorry.
1148            if (!$this->isTableUsed('index_grlist')) {
1149                return false;
1150            }
1151
1152            return (bool)$connection->count(
1153                'phash',
1154                'index_grlist',
1155                [
1156                    'phash' => (int)$row['phash_t3'],
1157                    'gr_list' => $this->frontendUserGroupList,
1158                ]
1159            );
1160        }
1161        // Ordinary TYPO3 pages:
1162        if ((string)$row['gr_list'] !== (string)$this->frontendUserGroupList) {
1163            // Selecting for the grlist records belonging to the phash-row where the current users gr_list exists.
1164            // If it is found it is proof that this user has direct access to the phash-rows content although
1165            // he did not himself initiate the indexing...
1166            if (!$this->isTableUsed('index_grlist')) {
1167                return false;
1168            }
1169
1170            return (bool)$connection->count(
1171                'phash',
1172                'index_grlist',
1173                [
1174                        'phash' => (int)$row['phash'],
1175                        'gr_list' => $this->frontendUserGroupList,
1176                    ]
1177            );
1178        }
1179        return true;
1180    }
1181
1182    /**
1183     * Returns "DESC" or "" depending on the settings of the incoming
1184     * highest/lowest result order (piVars['desc'])
1185     *
1186     * @param bool $inverse If TRUE, inverse the order which is defined by piVars['desc']
1187     * @return string " DESC" or formerly known as tx_indexedsearch_pi->isDescending
1188     */
1189    protected function getDescendingSortOrderFlag($inverse = false)
1190    {
1191        $desc = $this->descendingSortOrderFlag;
1192        if ($inverse) {
1193            $desc = !$desc;
1194        }
1195        return !$desc ? ' DESC' : '';
1196    }
1197
1198    /**
1199     * Returns if an item type is a multipage item type
1200     *
1201     * @param string $itemType Item type
1202     * @return bool TRUE if multipage capable
1203     */
1204    protected function multiplePagesType($itemType)
1205    {
1206        /** @var \TYPO3\CMS\IndexedSearch\FileContentParser $fileContentParser */
1207        $fileContentParser = $this->externalParsers[$itemType] ?? null;
1208        return is_object($fileContentParser) && $fileContentParser->isMultiplePageExtension($itemType);
1209    }
1210
1211    /**
1212     * md5 integer hash
1213     * Using 7 instead of 8 just because that makes the integers lower than
1214     * 32 bit (28 bit) and so they do not interfere with UNSIGNED integers
1215     * or PHP-versions which has varying output from the hexdec function.
1216     *
1217     * @param string $str String to hash
1218     * @return int Integer interpretation of the md5 hash of input string.
1219     */
1220    protected function md5inthash($str)
1221    {
1222        return IndexedSearchUtility::md5inthash($str);
1223    }
1224
1225    /**
1226     * Check if the tables provided are configured for usage.
1227     * This becomes necessary for extensions that provide additional database
1228     * functionality like indexed_search_mysql.
1229     *
1230     * @param string $table_list Comma-separated list of tables
1231     * @return bool TRUE if given tables are enabled
1232     */
1233    protected function isTableUsed($table_list)
1234    {
1235        return IndexedSearchUtility::isTableUsed($table_list);
1236    }
1237
1238    /**
1239     * Returns an object reference to the hook object if any
1240     *
1241     * @param string $functionName Name of the function you want to call / hook key
1242     * @return object|null Hook object, if any. Otherwise NULL.
1243     */
1244    public function hookRequest($functionName)
1245    {
1246        // Hook: menuConfig_preProcessModMenu
1247        if ($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['pi1_hooks'][$functionName] ?? false) {
1248            $hookObj = GeneralUtility::makeInstance($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['indexed_search']['pi1_hooks'][$functionName]);
1249            if (method_exists($hookObj, $functionName)) {
1250                $hookObj->pObj = $this;
1251                return $hookObj;
1252            }
1253        }
1254        return null;
1255    }
1256
1257    /**
1258     * Search type
1259     * e.g. sentence (20), any part of the word (1)
1260     *
1261     * @return int
1262     */
1263    public function getSearchType(): int
1264    {
1265        return $this->searchType;
1266    }
1267
1268    /**
1269     * A list of integer which should be root-pages to search from
1270     *
1271     * @return int[]
1272     */
1273    public function getSearchRootPageIdList()
1274    {
1275        return GeneralUtility::intExplode(',', $this->searchRootPageIdList);
1276    }
1277
1278    /**
1279     * Getter for joinPagesForQuery flag
1280     * enabled through TypoScript 'settings.skipExtendToSubpagesChecking'
1281     *
1282     * @return bool
1283     */
1284    public function getJoinPagesForQuery()
1285    {
1286        return $this->joinPagesForQuery;
1287    }
1288
1289    /**
1290     * @return \TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController
1291     */
1292    protected function getTypoScriptFrontendController()
1293    {
1294        return $GLOBALS['TSFE'];
1295    }
1296
1297    /**
1298     * @return TimeTracker
1299     */
1300    protected function getTimeTracker()
1301    {
1302        return GeneralUtility::makeInstance(TimeTracker::class);
1303    }
1304}
1305