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