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\Backend\Search\LiveSearch;
17
18use TYPO3\CMS\Backend\Routing\UriBuilder;
19use TYPO3\CMS\Backend\Tree\View\PageTreeView;
20use TYPO3\CMS\Backend\Utility\BackendUtility;
21use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
22use TYPO3\CMS\Core\Database\Connection;
23use TYPO3\CMS\Core\Database\ConnectionPool;
24use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression;
25use TYPO3\CMS\Core\Database\Query\QueryBuilder;
26use TYPO3\CMS\Core\Database\Query\QueryHelper;
27use TYPO3\CMS\Core\Database\Query\Restriction\EndTimeRestriction;
28use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
29use TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction;
30use TYPO3\CMS\Core\Imaging\Icon;
31use TYPO3\CMS\Core\Imaging\IconFactory;
32use TYPO3\CMS\Core\Localization\LanguageService;
33use TYPO3\CMS\Core\Type\Bitmask\Permission;
34use TYPO3\CMS\Core\Utility\GeneralUtility;
35use TYPO3\CMS\Core\Utility\MathUtility;
36
37/**
38 * Class for handling backend live search.
39 * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
40 */
41class LiveSearch
42{
43    /**
44     * @var int
45     */
46    const RECURSIVE_PAGE_LEVEL = 99;
47
48    /**
49     * @var string
50     */
51    private $queryString = '';
52
53    /**
54     * @var int
55     */
56    private $startCount = 0;
57
58    /**
59     * @var int
60     */
61    private $limitCount = 5;
62
63    /**
64     * @var string
65     */
66    protected $userPermissions = '';
67
68    /**
69     * @var QueryParser
70     */
71    protected $queryParser;
72
73    /**
74     * Initialize access settings
75     */
76    public function __construct()
77    {
78        $this->userPermissions = $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW);
79        $this->queryParser = GeneralUtility::makeInstance(QueryParser::class);
80    }
81
82    /**
83     * Find records from database based on the given $searchQuery.
84     *
85     * @param string $searchQuery
86     * @return array Result list of database search.
87     */
88    public function find($searchQuery)
89    {
90        $recordArray = [];
91        $pageList = [];
92        $mounts = $this->getBackendUser()->returnWebmounts();
93        foreach ($mounts as $pageId) {
94            $pageList[] = $this->getAvailablePageIds($pageId, self::RECURSIVE_PAGE_LEVEL);
95        }
96        $pageIdList = array_unique(explode(',', implode(',', $pageList)));
97        unset($pageList);
98        if ($this->queryParser->isValidCommand($searchQuery)) {
99            $this->setQueryString($this->queryParser->getSearchQueryValue($searchQuery));
100            $tableName = $this->queryParser->getTableNameFromCommand($searchQuery);
101            if ($tableName) {
102                $recordArray[] = $this->findByTable($tableName, $pageIdList, $this->startCount, $this->limitCount);
103            }
104        } else {
105            $this->setQueryString($searchQuery);
106            $recordArray = $this->findByGlobalTableList($pageIdList);
107        }
108        return $recordArray;
109    }
110
111    /**
112     * Find records from all registered TCA table & column values.
113     *
114     * @param array $pageIdList Comma separated list of page IDs
115     * @return array Records found in the database matching the searchQuery
116     */
117    protected function findByGlobalTableList($pageIdList)
118    {
119        $limit = $this->limitCount;
120        $getRecordArray = [];
121        foreach ($GLOBALS['TCA'] as $tableName => $value) {
122            // if no access for the table (read or write) or table is hidden, skip this table
123            if (
124                (isset($value['ctrl']['hideTable']) && $value['ctrl']['hideTable'])
125                ||
126                (
127                    !$this->getBackendUser()->check('tables_select', $tableName) &&
128                    !$this->getBackendUser()->check('tables_modify', $tableName)
129                )
130            ) {
131                continue;
132            }
133            $recordArray = $this->findByTable($tableName, $pageIdList, 0, $limit);
134            $recordCount = count($recordArray);
135            if ($recordCount) {
136                $limit -= $recordCount;
137                $getRecordArray[] = $recordArray;
138                if ($limit <= 0) {
139                    break;
140                }
141            }
142        }
143        return $getRecordArray;
144    }
145
146    /**
147     * Find records by given table name.
148     *
149     * @param string $tableName Database table name
150     * @param array $pageIdList Comma separated list of page IDs
151     * @param int $firstResult
152     * @param int $maxResults
153     * @return array Records found in the database matching the searchQuery
154     * @see getRecordArray()
155     * @see makeQuerySearchByTable()
156     * @see extractSearchableFieldsFromTable()
157     */
158    protected function findByTable($tableName, $pageIdList, $firstResult, $maxResults)
159    {
160        $fieldsToSearchWithin = $this->extractSearchableFieldsFromTable($tableName);
161        $getRecordArray = [];
162        if (!empty($fieldsToSearchWithin)) {
163            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
164                ->getQueryBuilderForTable($tableName);
165            $queryBuilder->getRestrictions()
166                ->removeByType(HiddenRestriction::class)
167                ->removeByType(StartTimeRestriction::class)
168                ->removeByType(EndTimeRestriction::class);
169
170            $queryBuilder
171                ->select('*')
172                ->from($tableName)
173                ->where(
174                    $queryBuilder->expr()->in(
175                        'pid',
176                        $queryBuilder->createNamedParameter($pageIdList, Connection::PARAM_INT_ARRAY)
177                    ),
178                    $this->makeQuerySearchByTable($queryBuilder, $tableName, $fieldsToSearchWithin)
179                )
180                ->setFirstResult($firstResult)
181                ->setMaxResults($maxResults);
182
183            if ($tableName === 'pages' && $this->userPermissions) {
184                $queryBuilder->andWhere($this->userPermissions);
185            }
186
187            $orderBy = $GLOBALS['TCA'][$tableName]['ctrl']['sortby'] ?: $GLOBALS['TCA'][$tableName]['ctrl']['default_sortby'];
188            foreach (QueryHelper::parseOrderBy((string)$orderBy) as $orderPair) {
189                [$fieldName, $order] = $orderPair;
190                $queryBuilder->addOrderBy($fieldName, $order);
191            }
192
193            $getRecordArray = $this->getRecordArray($queryBuilder, $tableName);
194        }
195
196        return $getRecordArray;
197    }
198
199    /**
200     * Process the Database operation to get the search result.
201     *
202     * @param QueryBuilder $queryBuilder Database table name
203     * @param string $tableName
204     * @return array
205     * @see getTitleFromCurrentRow()
206     * @see getEditLink()
207     */
208    protected function getRecordArray($queryBuilder, $tableName)
209    {
210        $collect = [];
211        $result = $queryBuilder->execute();
212        $iconFactory = GeneralUtility::makeInstance(IconFactory::class);
213        while ($row = $result->fetch()) {
214            BackendUtility::workspaceOL($tableName, $row);
215            if (!is_array($row)) {
216                continue;
217            }
218            $onlineUid = $row['t3ver_oid'] ?: $row['uid'];
219            $title = 'id=' . $row['uid'] . ', pid=' . $row['pid'];
220            $collect[$onlineUid] = [
221                'id' => $tableName . ':' . $row['uid'],
222                'pageId' => $tableName === 'pages' ? $row['uid'] : $row['pid'],
223                'typeLabel' =>  htmlspecialchars($this->getTitleOfCurrentRecordType($tableName)),
224                'iconHTML' => '<span title="' . htmlspecialchars($title) . '">' . $iconFactory->getIconForRecord($tableName, $row, Icon::SIZE_SMALL)->render() . '</span>',
225                'title' => htmlspecialchars(BackendUtility::getRecordTitle($tableName, $row)),
226                'editLink' => htmlspecialchars($this->getEditLink($tableName, $row))
227            ];
228        }
229        return $collect;
230    }
231
232    /**
233     * Build a backend edit link based on given record.
234     *
235     * @param string $tableName Record table name
236     * @param array $row Current record row from database.
237     * @return string Link to open an edit window for record.
238     * @see \TYPO3\CMS\Backend\Utility\BackendUtility::readPageAccess()
239     */
240    protected function getEditLink($tableName, $row)
241    {
242        $backendUser = $this->getBackendUser();
243        $editLink = '';
244        if ($tableName === 'pages') {
245            $permsEdit = $backendUser->calcPerms(BackendUtility::getRecord('pages', $row['uid']) ?? []) & Permission::PAGE_EDIT;
246        } else {
247            $permsEdit = $backendUser->calcPerms(BackendUtility::readPageAccess($row['pid'], $this->userPermissions) ?: []) & Permission::CONTENT_EDIT;
248        }
249        // "Edit" link - Only with proper edit permissions
250        if (!($GLOBALS['TCA'][$tableName]['ctrl']['readOnly'] ?? false)
251            && (
252                $backendUser->isAdmin()
253                || (
254                    $permsEdit
255                    && !($GLOBALS['TCA'][$tableName]['ctrl']['adminOnly'] ?? false)
256                    && $backendUser->check('tables_modify', $tableName)
257                    && $backendUser->recordEditAccessInternals($tableName, $row)
258                )
259            )
260        ) {
261            $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
262            $returnUrl = (string)$uriBuilder->buildUriFromRoute('web_list', ['id' => $row['pid']]);
263            $editLink = (string)$uriBuilder->buildUriFromRoute('record_edit', [
264                'edit[' . $tableName . '][' . $row['uid'] . ']' => 'edit',
265                'returnUrl' => $returnUrl
266            ]);
267        }
268        return $editLink;
269    }
270
271    /**
272     * Retrieve the record name
273     *
274     * @param string $tableName Record table name
275     * @return string
276     */
277    protected function getTitleOfCurrentRecordType($tableName)
278    {
279        return $this->getLanguageService()->sL($GLOBALS['TCA'][$tableName]['ctrl']['title']);
280    }
281
282    /**
283     * Build the MySql where clause by table.
284     *
285     * @param QueryBuilder $queryBuilder
286     * @param string $tableName Record table name
287     * @param array $fieldsToSearchWithin User right based visible fields where we can search within.
288     * @return CompositeExpression
289     */
290    protected function makeQuerySearchByTable(QueryBuilder &$queryBuilder, $tableName, array $fieldsToSearchWithin)
291    {
292        $constraints = [];
293
294        // If the search string is a simple integer, assemble an equality comparison
295        if (MathUtility::canBeInterpretedAsInteger($this->queryString)) {
296            foreach ($fieldsToSearchWithin as $fieldName) {
297                if ($fieldName !== 'uid'
298                    && $fieldName !== 'pid'
299                    && !isset($GLOBALS['TCA'][$tableName]['columns'][$fieldName])
300                ) {
301                    continue;
302                }
303                $fieldConfig = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
304                $fieldType = $fieldConfig['type'];
305                $evalRules = $fieldConfig['eval'] ?: '';
306
307                // Assemble the search condition only if the field is an integer, or is uid or pid
308                if ($fieldName === 'uid'
309                    || $fieldName === 'pid'
310                    || ($fieldType === 'input' && $evalRules && GeneralUtility::inList($evalRules, 'int'))
311                ) {
312                    $constraints[] = $queryBuilder->expr()->eq(
313                        $fieldName,
314                        $queryBuilder->createNamedParameter($this->queryString, \PDO::PARAM_INT)
315                    );
316                } elseif ($fieldType === 'text'
317                    || $fieldType === 'flex'
318                    || $fieldType === 'slug'
319                    || ($fieldType === 'input' && (!$evalRules || !preg_match('/\b(?:date|time|int)\b/', $evalRules)))
320                ) {
321                    // Otherwise and if the field makes sense to be searched, assemble a like condition
322                    $constraints[] = $constraints[] = $queryBuilder->expr()->like(
323                        $fieldName,
324                        $queryBuilder->createNamedParameter(
325                            '%' . $queryBuilder->escapeLikeWildcards((int)$this->queryString) . '%',
326                            \PDO::PARAM_STR
327                        )
328                    );
329                }
330            }
331        } else {
332            $like = '%' . $queryBuilder->escapeLikeWildcards($this->queryString) . '%';
333            foreach ($fieldsToSearchWithin as $fieldName) {
334                if (!isset($GLOBALS['TCA'][$tableName]['columns'][$fieldName])) {
335                    continue;
336                }
337                $fieldConfig = &$GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'];
338                $fieldType = $fieldConfig['type'];
339                $evalRules = $fieldConfig['eval'] ?: '';
340
341                // Check whether search should be case-sensitive or not
342                $searchConstraint = $queryBuilder->expr()->andX(
343                    $queryBuilder->expr()->comparison(
344                        'LOWER(' . $queryBuilder->quoteIdentifier($fieldName) . ')',
345                        'LIKE',
346                        $queryBuilder->createNamedParameter(mb_strtolower($like), \PDO::PARAM_STR)
347                    )
348                );
349
350                if (is_array($fieldConfig['search'])) {
351                    if (in_array('case', $fieldConfig['search'], true)) {
352                        // Replace case insensitive default constraint
353                        $searchConstraint = $queryBuilder->expr()->andX(
354                            $queryBuilder->expr()->like(
355                                $fieldName,
356                                $queryBuilder->createNamedParameter($like, \PDO::PARAM_STR)
357                            )
358                        );
359                    }
360                    // Apply additional condition, if any
361                    if ($fieldConfig['search']['andWhere']) {
362                        $searchConstraint->add(
363                            QueryHelper::stripLogicalOperatorPrefix($fieldConfig['search']['andWhere'])
364                        );
365                    }
366                }
367                // Assemble the search condition only if the field makes sense to be searched
368                if ($fieldType === 'text'
369                    || $fieldType === 'flex'
370                    || $fieldType === 'slug'
371                    || ($fieldType === 'input' && (!$evalRules || !preg_match('/\b(?:date|time|int)\b/', $evalRules)))
372                ) {
373                    if ($searchConstraint->count() !== 0) {
374                        $constraints[] = $searchConstraint;
375                    }
376                }
377            }
378        }
379
380        // If no search field conditions have been build ensure no results are returned
381        if (empty($constraints)) {
382            return '0=1';
383        }
384
385        return $queryBuilder->expr()->orX(...$constraints);
386    }
387
388    /**
389     * Get all fields from given table where we can search for.
390     *
391     * @param string $tableName Name of the table for which to get the searchable fields
392     * @return array
393     */
394    protected function extractSearchableFieldsFromTable($tableName)
395    {
396        // Get the list of fields to search in from the TCA, if any
397        if (isset($GLOBALS['TCA'][$tableName]['ctrl']['searchFields'])) {
398            $fieldListArray = GeneralUtility::trimExplode(',', $GLOBALS['TCA'][$tableName]['ctrl']['searchFields'], true);
399        } else {
400            $fieldListArray = [];
401        }
402        // Add special fields
403        if ($this->getBackendUser()->isAdmin()) {
404            $fieldListArray[] = 'uid';
405            $fieldListArray[] = 'pid';
406        }
407        return $fieldListArray;
408    }
409
410    /**
411     * Setter for limit value.
412     *
413     * @param int $limitCount
414     */
415    public function setLimitCount($limitCount)
416    {
417        $limit = MathUtility::convertToPositiveInteger($limitCount);
418        if ($limit > 0) {
419            $this->limitCount = $limit;
420        }
421    }
422
423    /**
424     * Setter for start count value.
425     *
426     * @param int $startCount
427     */
428    public function setStartCount($startCount)
429    {
430        $this->startCount = MathUtility::convertToPositiveInteger($startCount);
431    }
432
433    /**
434     * Setter for the search query string.
435     *
436     * @param string $queryString
437     */
438    public function setQueryString($queryString)
439    {
440        $this->queryString = $queryString;
441    }
442
443    /**
444     * Creates an instance of \TYPO3\CMS\Backend\Tree\View\PageTreeView which will select a
445     * page tree to $depth and return the object. In that object we will find the ids of the tree.
446     *
447     * @param int $id Page id.
448     * @param int $depth Depth to go down.
449     * @return string Comma separated list of uids
450     */
451    protected function getAvailablePageIds($id, $depth)
452    {
453        $tree = GeneralUtility::makeInstance(PageTreeView::class);
454        $tree->init('AND ' . $this->userPermissions);
455        $tree->makeHTML = 0;
456        $tree->fieldArray = ['uid', 'php_tree_stop'];
457        if ($depth) {
458            $tree->getTree($id, $depth, '');
459        }
460        $tree->ids[] = $id;
461        // add workspace pid - workspace permissions are taken into account by where clause later
462        $tree->ids[] = -1;
463        return implode(',', $tree->ids);
464    }
465
466    protected function getBackendUser(): BackendUserAuthentication
467    {
468        return $GLOBALS['BE_USER'];
469    }
470
471    /**
472     * @return LanguageService|null
473     */
474    protected function getLanguageService(): ?LanguageService
475    {
476        return $GLOBALS['LANG'] ?? null;
477    }
478}
479