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