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