languageService = $this->languageServiceFactory->createFromUserPreferences($this->getBackendUser()); $this->userPermissions = $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW); } public function getFilterLabel(): string { return $this->languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:liveSearch.databaseRecordProvider.filterLabel'); } public function count(SearchDemand $searchDemand): int { $count = 0; $event = $this->eventDispatcher->dispatch( new BeforeSearchInDatabaseRecordProviderEvent($this->getPageIdList(), $searchDemand) ); $this->pageIdList = $event->getSearchPageIds(); $searchDemand = $event->getSearchDemand(); $accessibleTables = $this->getAccessibleTables($event); $parsedCommand = $this->parseCommand($searchDemand); $searchDemand = $parsedCommand['searchDemand']; if ($parsedCommand['table'] !== null && in_array($parsedCommand['table'], $accessibleTables)) { $accessibleTables = [$parsedCommand['table']]; } foreach ($accessibleTables as $tableName) { $count += $this->countByTable($searchDemand, $tableName); } return $count; } /** * @return ResultItem[] */ public function find(SearchDemand $searchDemand): array { $result = []; $remainingItems = $searchDemand->getLimit(); $offset = $searchDemand->getOffset(); if ($remainingItems < 1) { return []; } $event = $this->eventDispatcher->dispatch( new BeforeSearchInDatabaseRecordProviderEvent($this->getPageIdList(), $searchDemand) ); $this->pageIdList = $event->getSearchPageIds(); $searchDemand = $event->getSearchDemand(); $accessibleTables = $this->getAccessibleTables($event); $parsedCommand = $this->parseCommand($searchDemand); $searchDemand = $parsedCommand['searchDemand']; if ($parsedCommand['table'] !== null && in_array($parsedCommand['table'], $accessibleTables)) { $accessibleTables = [$parsedCommand['table']]; } foreach ($accessibleTables as $tableName) { if ($remainingItems < 1) { break; } // To have a reliable offset calculation across several database tables, we have to count the amount of // records and subtract the amount from the offset to be used, IF the amount is smaller than the requested // offset. At any point, the offset will be smaller than the amount of records, which will then be used in // ->findByTable(). // If any subsequent ->findByTable() call returns a result, the offset becomes irrelevant and is then zeroed. if ($offset > 0) { $tableCount = $this->countByTable($searchDemand, $tableName); if ($tableCount <= $offset) { $offset = max(0, $offset - $tableCount); continue; } } $tableResult = $this->findByTable($searchDemand, $tableName, $remainingItems, $offset); if ($tableResult !== []) { $remainingItems -= count($tableResult); $offset = 0; $result[] = $tableResult; } } return array_merge([], ...$result); } private function parseCommand(SearchDemand $searchDemand): array { $tableName = null; $commandQuery = null; $query = $searchDemand->getQuery(); if ($this->queryParser->isValidCommand($query)) { $commandQuery = $query; } elseif ($this->queryParser->isValidPageJump($query)) { $commandQuery = $this->queryParser->getCommandForPageJump($query); } if ($commandQuery !== null) { $tableName = $this->queryParser->getTableNameFromCommand($query); $extractedQueryString = $this->queryParser->getSearchQueryValue($commandQuery); $searchDemand = new SearchDemand([ new DemandProperty(DemandPropertyName::query, $extractedQueryString), ...array_filter( $searchDemand->getProperties(), static fn(DemandProperty $demandProperty): bool => $demandProperty->getName() !== DemandPropertyName::query ), ]); } return [ 'searchDemand' => $searchDemand, 'table' => $tableName, ]; } private function getQueryBuilderForTable(SearchDemand $searchDemand, string $tableName): ?QueryBuilder { $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); $queryBuilder->getRestrictions() ->removeAll() ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace, true)); $constraints = $this->buildConstraintsForTable($searchDemand->getQuery(), $queryBuilder, $tableName); $event = $this->eventDispatcher->dispatch(new ModifyConstraintsForLiveSearchEvent($constraints, $tableName, $searchDemand)); $constraints = $event->getConstraints(); if ($constraints === []) { return null; } $queryBuilder ->from($tableName) ->where( $queryBuilder->expr()->or(...$constraints) ); if ($this->pageIdList !== []) { $queryBuilder->andWhere( $queryBuilder->expr()->in( 'pid', $queryBuilder->createNamedParameter($this->pageIdList, ArrayParameterType::INTEGER) ) ); } /** @var ModifyQueryForLiveSearchEvent $event */ $event = $this->eventDispatcher->dispatch(new ModifyQueryForLiveSearchEvent($queryBuilder, $tableName)); return $event->getQueryBuilder(); } private function countByTable(SearchDemand $searchDemand, string $tableName): int { $queryBuilder = $this->getQueryBuilderForTable($searchDemand, $tableName); return (int)$queryBuilder?->count('*')->executeQuery()->fetchOne(); } /** * @return ResultItem[] */ private function findByTable(SearchDemand $searchDemand, string $tableName, int $limit, int $offset): array { $queryBuilder = $this->getQueryBuilderForTable($searchDemand, $tableName); if ($queryBuilder === null) { return []; } $queryBuilder ->select('*') ->setFirstResult($offset) ->setMaxResults($limit); $queryBuilder->addOrderBy('uid', 'DESC'); $items = []; $result = $queryBuilder->executeQuery(); $schema = $this->tcaSchemaFactory->get($tableName); $rootLevelCapability = $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel); $hasWorkspaceCapability = $schema->hasCapability(TcaSchemaCapability::Workspace); while ($row = $result->fetchAssociative()) { BackendUtility::workspaceOL($tableName, $row); if (!is_array($row)) { continue; } $actions = []; $editActionLink = $this->getEditActionLink($tableName, $row); if ($editActionLink !== '') { $actions[DatabaseRecordActionType::EDIT->value] = (new ResultItemAction(DatabaseRecordActionType::EDIT->value)) ->setLabel($this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.edit')) ->setIcon($this->iconFactory->getIcon('actions-open', IconSize::SMALL)) ->setUrl($editActionLink); } $layoutActionLink = $this->getLayoutActionLink($tableName, $row); if ($layoutActionLink !== '') { $actions[DatabaseRecordActionType::LAYOUT->value] = (new ResultItemAction(DatabaseRecordActionType::LAYOUT->value)) ->setLabel($this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.view.layout')) ->setIcon($this->iconFactory->getIcon('actions-viewmode-layout', IconSize::SMALL)) ->setUrl($layoutActionLink); } $listActionLink = $this->getRecordsActionLink($tableName, $row); if ($listActionLink !== '') { $actions[DatabaseRecordActionType::LIST->value] = (new ResultItemAction(DatabaseRecordActionType::LIST->value)) ->setLabel($this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.showList')) ->setIcon($this->iconFactory->getIcon('actions-list', IconSize::SMALL)) ->setUrl($listActionLink); } $previewActionLink = $this->getPreviewActionLink($tableName, $row); if ($previewActionLink !== '') { $actions[DatabaseRecordActionType::PREVIEW->value] = (new ResultItemAction(DatabaseRecordActionType::PREVIEW->value)) ->setLabel($this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.showPage')) ->setIcon($this->iconFactory->getIcon('actions-file-view', IconSize::SMALL)) ->setUrl($previewActionLink); } // Find the default action $defaultActionIdentifier = DatabaseRecordActionType::fromUserForTable($this->getBackendUser(), $tableName); $defaultAction = $actions[$defaultActionIdentifier->value] ?? null; $extraData = [ 'table' => $tableName, 'uid' => $row['uid'], 'inWorkspace' => $hasWorkspaceCapability && $row['t3ver_wsid'] > 0, ]; if ($rootLevelCapability->canExistOnPages()) { $extraData['breadcrumb'] = BackendUtility::getRecordPath($row['pid'], 'AND ' . $this->userPermissions, 0); } $language = null; if ($schema->hasCapability(TcaSchemaCapability::Language)) { $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); $languageFieldName = $languageCapability->getLanguageField()->getName(); $languageId = (int)($row[$languageFieldName] ?? 0); $language = $this->resolveLanguage((int)($row['pid'] ?? 0), $languageId); } $icon = $this->iconFactory->getIconForRecord($tableName, $row, IconSize::SMALL); $recordTitle = BackendUtility::getRecordTitle($tableName, $row); $items[] = (new ResultItem(self::class)) ->setItemTitle(BackendUtility::cropToTitleLength($recordTitle)) ->setTypeLabel($schema->getTitle($this->languageService->sL(...)) ?: $tableName) ->setIcon($icon) ->setActions(...array_values($actions)) ->setDefaultAction($defaultAction) ->setLanguage($language) ->setExtraData($extraData) ->setInternalData([ 'row' => $row, ]) ; } return $items; } private function canAccessTable(string $tableName): bool { if (!$this->tcaSchemaFactory->has($tableName)) { return true; } $schema = $this->tcaSchemaFactory->get($tableName); if ($schema->hasCapability(TcaSchemaCapability::HideInUi)) { return false; } if (!$this->getBackendUser()->check('tables_select', $tableName) && !$this->getBackendUser()->check('tables_modify', $tableName)) { return false; } return true; } private function getAccessibleTables(BeforeSearchInDatabaseRecordProviderEvent $event): array { return array_filter($this->tcaSchemaFactory->all()->getNames(), function (string $tableName) use ($event): bool { return $this->canAccessTable($tableName) && !$event->isTableIgnored($tableName); }); } /** * List of available page uids for user, empty array for admin users. * * @return int[] */ private function getPageIdList(): array { if ($this->getBackendUser()->isAdmin()) { return []; } $mounts = $this->getBackendUser()->getWebmounts(); $pageList = $mounts; $repository = GeneralUtility::makeInstance(PageTreeRepository::class); $repository->setAdditionalWhereClause($this->userPermissions); $pages = $repository->getFlattenedPages($mounts, self::RECURSIVE_PAGE_LEVEL); foreach ($pages as $page) { $pageList[] = (int)$page['uid']; } return $pageList; } /** * @return CompositeExpression[] */ private function buildConstraintsForTable(string $queryString, QueryBuilder $queryBuilder, string $tableName): array { $platform = $queryBuilder->getConnection()->getDatabasePlatform(); $isPostgres = $platform instanceof DoctrinePostgreSQLPlatform; $fieldsToSearchWithin = $this->searchableSchemaFieldsCollector->getFields($tableName); [$subSchemaDivisorFieldName, $fieldsSubSchemaTypes] = $this->searchableSchemaFieldsCollector->getSchemaFieldSubSchemaTypes($tableName); $constraints = []; // If the search string is a simple integer, assemble an equality comparison if (MathUtility::canBeInterpretedAsInteger($queryString)) { // Add uid and pid constraint $constraints[] = $queryBuilder->expr()->eq( 'uid', $queryBuilder->createNamedParameter($queryString, Connection::PARAM_INT) ); $constraints[] = $queryBuilder->expr()->eq( 'pid', $queryBuilder->createNamedParameter($queryString, Connection::PARAM_INT) ); foreach ($fieldsToSearchWithin as $fieldName => $field) { // Assemble the search condition only if the field is an integer if ($field instanceof NumberFieldType || $field instanceof DateTimeFieldType) { $searchConstraint = $queryBuilder->expr()->eq( $fieldName, $queryBuilder->createNamedParameter($queryString, Connection::PARAM_INT) ); } else { // Otherwise assemble a like condition $searchConstraint = $queryBuilder->expr()->like( $fieldName, $queryBuilder->createNamedParameter( '%' . $queryBuilder->escapeLikeWildcards($queryString) . '%' ) ); } // If this table has subtypes (e.g. tt_content.CType), we want to ensure that only CType that contain // e.g. "bodytext" in their list of fields, to search through them. This is important when a field // is filled but its type has been changed. if ($subSchemaDivisorFieldName !== '' && isset($fieldsSubSchemaTypes[$fieldName]) && $fieldsSubSchemaTypes[$fieldName] !== [] ) { // Using `IN()` with a string-value quoted list is fine for all database systems, even when // used on integer-typed fields and no additional work required here to mitigate something. $searchConstraint = $queryBuilder->expr()->and( $searchConstraint, $queryBuilder->expr()->in( $subSchemaDivisorFieldName, $queryBuilder->quoteArrayBasedValueListToStringList($fieldsSubSchemaTypes[$fieldName]) ), ); } $constraints[] = $searchConstraint; } } else { $like = '%' . $queryBuilder->escapeLikeWildcards($queryString) . '%'; foreach ($fieldsToSearchWithin as $fieldName => $field) { // Enforce case-insensitive comparison by lower-casing field and value, unrelated to charset/collation // on MySQL/MariaDB, for example if column collation is `utf8mb4_bin` - which would be case-sensitive. $preparedFieldName = $isPostgres ? $queryBuilder->castFieldToTextType($fieldName) : $queryBuilder->quoteIdentifier($fieldName); $searchConstraint = $queryBuilder->expr()->comparison( 'LOWER(' . $preparedFieldName . ')', 'LIKE', $queryBuilder->createNamedParameter(mb_strtolower($like)) ); // If this table has subtypes (e.g. tt_content.CType), we want to ensure that only CType that contain // e.g. "bodytext" in their list of fields, to search through them. This is important when a field // is filled but its type has been changed. if ($subSchemaDivisorFieldName !== '' && isset($fieldsSubSchemaTypes[$fieldName]) && $fieldsSubSchemaTypes[$fieldName] !== [] ) { // Using `IN()` with a string-value quoted list is fine for all database systems, even when // used on integer-typed fields and no additional work required here to mitigate something. $searchConstraint = $queryBuilder->expr()->and( $searchConstraint, $queryBuilder->expr()->in( $subSchemaDivisorFieldName, $queryBuilder->quoteArrayBasedValueListToStringList($fieldsSubSchemaTypes[$fieldName]) ), ); } $constraints[] = $searchConstraint; } } return $constraints; } /** * Build a backend edit link based on given record. * * @param string $tableName Record table name * @param array $row Current record row from database. * @return string Link to open an edit window for record. * @see \TYPO3\CMS\Backend\Utility\BackendUtility::readPageAccess() */ private function getEditActionLink(string $tableName, array $row): string { $backendUser = $this->getBackendUser(); $editLink = ''; $permissionSet = new Permission($backendUser->calcPerms(BackendUtility::readPageAccess($row['pid'], $this->userPermissions) ?: [])); $schema = $this->tcaSchemaFactory->get($tableName); if (!$schema->hasCapability(TcaSchemaCapability::AccessReadOnly) && ( $backendUser->isAdmin() || ( $permissionSet->editContentPermissionIsGranted() && !$schema->hasCapability(TcaSchemaCapability::AccessAdminOnly) && $backendUser->check('tables_modify', $tableName) && $backendUser->checkRecordEditAccess($tableName, $row)->isAllowed ) ) ) { // @todo pass module context to live search and pass module context to edit link and use for return url $returnUrl = (string)$this->uriBuilder->buildUriFromRoute('records', ['id' => $row['pid']]); $editLink = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [ 'edit[' . $tableName . '][' . $row['uid'] . ']' => 'edit', 'returnUrl' => $returnUrl, ]); } return $editLink; } /** * Build a link to the page layout for the given record. * * @param array $row Current record row from database. * @return string Link to open an edit window for record. */ private function getLayoutActionLink(string $tableName, array $row): string { $showLink = ''; if ($tableName !== 'tt_content') { return $showLink; } if ($this->hasPagesAccess($row)) { $parameter = [ 'id' => $row['pid'], 'languages' => [$row['language_tag']], ]; $showLink = ((string)$this->uriBuilder->buildUriFromRoute('web_layout', $parameter)) . '#element-' . $tableName . '-' . $row['uid']; } return $showLink; } /** * Build a link to the record list based on given record. * * @param array $row Current record row from database. * @return string Link to open an edit window for record. */ private function getRecordsActionLink(string $table, array $row): string { return $this->hasPagesAccess($row) ? (((string)$this->uriBuilder->buildUriFromRoute('records', ['id' => $row['pid']])) . '#t3-table-' . $table) : ''; } /** * Build a preview link to display the record in the frontend. * * @param array $row Current record row from database. * @return string Link to open an edit window for record. */ private function getPreviewActionLink(string $table, array $row): string { $previewLink = ''; if ($this->hasPagesAccess($row)) { $previewUriBuilder = PreviewUriBuilder::createForRecordPreview($table, $row, (int)($row['pid'] ?? 0)); if ($previewUriBuilder->isPreviewable()) { $previewLink = (string)$previewUriBuilder->buildUri(); } } return $previewLink; } private function hasPagesAccess(array $row): bool { $backendUser = $this->getBackendUser(); $permissionSet = new Permission($backendUser->calcPerms(BackendUtility::getRecord('pages', $row['pid']) ?? [])); $pagesSchema = $this->tcaSchemaFactory->get('pages'); return $backendUser->isAdmin() || ( $permissionSet->showPagePermissionIsGranted() && !$pagesSchema->hasCapability(TcaSchemaCapability::AccessAdminOnly) && $backendUser->check('tables_select', 'pages') ); } private function resolveLanguage(int $pageUid, int $languageId): ?array { try { $siteLanguage = $this->siteFinder->getSiteByPageId($pageUid)->getLanguageById($languageId); return [ 'id' => $siteLanguage->getLanguageId(), 'title' => $siteLanguage->getTitle(), 'iconIdentifier' => $siteLanguage->getFlagIdentifier(), ]; } catch (SiteNotFoundException|\InvalidArgumentException) { return null; } } private function getBackendUser(): BackendUserAuthentication { return $GLOBALS['BE_USER']; } }