getQueryParams()['readOnly'] ?? false) { $this->getBackendUser()->initializeWebmountsForElementBrowser(); } if ($request->getQueryParams()['alternativeEntryPoints'] ?? false) { $this->alternativeEntryPoints = $request->getQueryParams()['alternativeEntryPoints']; $this->alternativeEntryPoints = array_filter($this->alternativeEntryPoints, function (int $pageId): bool { return $this->getBackendUser()->isInWebMount($pageId) !== null; }); $this->alternativeEntryPoints = array_map(intval(...), $this->alternativeEntryPoints); $this->alternativeEntryPoints = array_unique($this->alternativeEntryPoints); } $userTsConfig = $this->getBackendUser()->getTSConfig(); $this->hiddenRecords = GeneralUtility::intExplode( ',', (string)($userTsConfig['options.']['hideRecords.']['pages'] ?? ''), true ); $this->labels = $userTsConfig['options.']['pageTree.']['label.'] ?? []; $this->addIdAsPrefix = (bool)($userTsConfig['options.']['pageTree.']['showPageIdWithTitle'] ?? false); $this->addDomainName = (bool)($userTsConfig['options.']['pageTree.']['showDomainNameWithTitle'] ?? false); $this->useNavTitle = (bool)($userTsConfig['options.']['pageTree.']['showNavTitle'] ?? false); $this->showMountPathAboveMounts = (bool)($userTsConfig['options.']['pageTree.']['showPathAboveMounts'] ?? false); $this->userHasAccessToModifyPagesAndToDefaultLanguage = $this->getBackendUser()->check('tables_modify', 'pages') && $this->getBackendUser()->checkLanguageAccess(0); } /** * Returns page tree configuration in JSON */ public function fetchConfigurationAction(ServerRequestInterface $request): ResponseInterface { $backendUser = $this->getBackendUser(); $userTsConfig = $backendUser->getTSConfig(); // Check if translation search feature is generally available (TSconfig setting) $translationSearchAvailable = (bool)($userTsConfig['options.']['pageTree.']['searchInTranslatedPages'] ?? true); // Determine if translation search is enabled by the user preference - otherwise TSconfig setting applies $translationSearchEnabled = $translationSearchAvailable && ( !isset($backendUser->uc['pageTree_searchInTranslatedPages']) || $backendUser->uc['pageTree_searchInTranslatedPages'] ); // Check if frontend URI search feature is generally available (TSconfig setting) $frontendUriSearchAvailable = (bool)($userTsConfig['options.']['pageTree.']['searchByFrontendUri'] ?? true); // Determine if frontend URI search is enabled by the user preference - otherwise TSconfig setting applies $frontendUriSearchEnabled = $frontendUriSearchAvailable && ( !isset($backendUser->uc['pageTree_searchByFrontendUri']) || $backendUser->uc['pageTree_searchByFrontendUri'] ); // Build language list from site configuration $languages = []; $currentLanguageTag = $_COOKIE['pageTreeLang'] ?? ''; try { $siteFinder = GeneralUtility::makeInstance(SiteFinder::class); $sites = $siteFinder->getAllSites(); foreach ($sites as $site) { foreach ($site->getAllLanguages() as $lang) { $tag = $lang->getLanguageTag(); $languages[] = [ 'languageTag' => $tag, 'title' => $lang->getTitle(), 'flag' => $lang->getFlagIdentifier(), ]; } break; } } catch (\Throwable) {} $dataUrlParams = []; if ($currentLanguageTag !== '') { $dataUrlParams['language'] = $currentLanguageTag; } $configuration = [ 'allowDragMove' => $this->isDragMoveAllowed(), 'doktypes' => $this->getDokTypes($request), 'displayDeleteConfirmation' => $backendUser->jsConfirmation(JsConfirmation::DELETE), 'temporaryMountPoint' => $this->getMountPointPath((int)($backendUser->uc['pageTree_temporaryMountPoint'] ?? 0)), 'showIcons' => true, 'dataUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_data', $dataUrlParams), 'rootlineUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_rootline'), 'filterUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_filter'), 'setTemporaryMountPointUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_set_temporary_mount_point'), 'searchInTranslatedPagesEnabled' => $translationSearchEnabled, 'searchInTranslatedPagesAvailable' => $translationSearchAvailable, 'searchByFrontendUriEnabled' => $frontendUriSearchEnabled, 'searchByFrontendUriAvailable' => $frontendUriSearchAvailable, 'languages' => $languages, 'currentLanguage' => $currentLanguageTag, ]; return new JsonResponse($configuration); } public function fetchReadOnlyConfigurationAction(ServerRequestInterface $request): ResponseInterface { $entryPoints = (string)($request->getQueryParams()['alternativeEntryPoints'] ?? ''); $entryPoints = GeneralUtility::intExplode(',', $entryPoints, true); $additionalArguments = [ 'readOnly' => 1, ]; if (!empty($entryPoints)) { $additionalArguments['alternativeEntryPoints'] = $entryPoints; } $configuration = [ 'displayDeleteConfirmation' => $this->getBackendUser()->jsConfirmation(JsConfirmation::DELETE), 'temporaryMountPoint' => $this->getMountPointPath((int)($this->getBackendUser()->uc['pageTree_temporaryMountPoint'] ?? 0)), 'showIcons' => true, 'dataUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_data', $additionalArguments), 'filterUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_filter', $additionalArguments), 'setTemporaryMountPointUrl' => (string)$this->uriBuilder->buildUriFromRoute('ajax_page_tree_set_temporary_mount_point'), 'nonViewableDoktypes' => $this->pageDoktypeRegistry->getNonViewableDoktypes(), ]; return new JsonResponse($configuration); } /** * Returns the list of doktypes to display in page tree toolbar drag area, * automatically determined based on the user's group permissions. */ protected function getDokTypes(ServerRequestInterface $request): array { $formDataGroup = GeneralUtility::makeInstance(OnTheFly::class); // Skip DatabaseUserPermissionCheck::class to return doktypes even if the user cannot create pages at root level $formDataGroup->setProviderList([ InitializeProcessedTca::class, DatabaseParentPageRow::class, DatabaseEffectivePid::class, UserTsConfig::class, PageTsConfig::class, DatabaseRowInitializeNew::class, DatabaseUniqueUidNewRow::class, TcaSelectItems::class, ]); try { $doktypes = $this->formDataCompiler ->compile( [ 'command' => 'new', 'request' => $request, 'tableName' => 'pages', 'vanillaUid' => 0, ], $formDataGroup )['processedTca']['columns']['doktype']['config']['items'] ?? []; } catch (\Exception) { return []; } return array_values( array_map( static fn(array $doktype) => [ 'nodeType' => $doktype['value'], 'icon' => $doktype['icon'] ?? '', 'title' => $doktype['label'] ?? '', ], array_filter( $doktypes, static fn(array $doktype) => ($doktype['value'] ?? '') !== '--div--' && ($doktype['value'] ?? '') !== '' ) ) ); } /** * Returns JSON representing page tree */ public function fetchDataAction(ServerRequestInterface $request): ResponseInterface { $this->initializeConfiguration($request); $languageParam = $request->getQueryParams()['language'] ?? ''; $languageTag = ($languageParam !== '' && $languageParam !== '0') ? $languageParam : null; $items = []; $parentIdentifier = $request->getQueryParams()['parent'] ?? null; if ($parentIdentifier) { $parentDepth = (int)($request->getQueryParams()['depth'] ?? 0); // Fetching a part of a page tree $entryPoints = $this->getAllEntryPointPageTrees((int)$parentIdentifier); $mountPid = (int)($request->getQueryParams()['mount'] ?? 0); $this->levelsToFetch = $parentDepth + $this->levelsToFetch; foreach ($entryPoints as $page) { $items[] = $this->pagesToFlatArray($page, $mountPid, $parentDepth); } } else { $entryPoints = $this->getAllEntryPointPageTrees(); foreach ($entryPoints as $page) { $items[] = $this->pagesToFlatArray($page, (int)$page['uid']); } } $items = array_merge(...$items); if ($languageTag !== null) { $this->applyTranslationOverlay($items, $languageTag); } return new JsonResponse($this->getPostProcessedPageItems($request, $items)); } private function applyTranslationOverlay(array &$items, string $languageTag): void { $pageIds = []; foreach ($items as $item) { $uid = (int)($item['identifier'] ?? 0); if ($uid > 0) { $pageIds[] = $uid; } } if (empty($pageIds)) { return; } $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('pages'); $translations = $queryBuilder ->select('l10n_parent', 'title', 'nav_title', 'language_tag') ->from('pages') ->where( $queryBuilder->expr()->in('l10n_parent', $pageIds), $queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter($languageTag)) ) ->executeQuery() ->fetchAllAssociative(); $translationMap = []; foreach ($translations as $trans) { $translationMap[(int)$trans['l10n_parent']] = $trans; } foreach ($items as &$item) { $page = $item['_page'] ?? []; $uid = (int)($page['uid'] ?? (int)($item['identifier'] ?? 0)); $trans = $translationMap[$uid] ?? null; if ($trans) { $item['name'] = $trans['title']; if (!empty($trans['nav_title'])) { $item['name'] = $trans['nav_title']; $item['nameSourceField'] = 'nav_title'; } $item['_page']['title'] = $trans['title']; $item['_page']['nav_title'] = $trans['nav_title'] ?? ''; $item['_page']['_translatedTitle'] = $trans['title']; } } } /** * Returns JSON representing page rootline */ public function fetchRootlineAction(ServerRequestInterface $request): ResponseInterface { $identifier = (string)($request->getQueryParams()['identifier'] ?? ''); if (!MathUtility::canBeInterpretedAsInteger($identifier)) { return new JsonResponse(null, 400); } $pageId = (int)$identifier; if ($pageId === 0) { return new JsonResponse(['rootline' => ['0']]); } $rootline = BackendUtility::BEgetRootLine((int)$identifier); if ($rootline === []) { return new JsonResponse(null, 404); } return new JsonResponse([ 'rootline' => array_map(strval(...), array_column(array_reverse($rootline), 'uid')), ]); } /** * Returns JSON representing page tree filtered by keyword */ public function filterDataAction(ServerRequestInterface $request): ResponseInterface { $searchQuery = $request->getQueryParams()['q'] ?? ''; if (trim($searchQuery) === '') { return new JsonResponse([]); } $this->initializeConfiguration($request); $this->expandAllNodes = true; $items = []; $entryPoints = $this->getAllEntryPointPageTrees(0, $searchQuery); foreach ($entryPoints as $page) { if (!empty($page)) { $items[] = $this->pagesToFlatArray($page, (int)$page['uid']); } } $items = array_merge(...$items); return new JsonResponse($this->getPostProcessedPageItems($request, $items)); } /** * Sets a temporary mount point * * @throws \RuntimeException */ public function setTemporaryMountPointAction(ServerRequestInterface $request): ResponseInterface { if (empty($request->getParsedBody()['pid'])) { throw new \RuntimeException( 'Required "pid" parameter is missing.', 1511792197 ); } $pid = (int)$request->getParsedBody()['pid']; $this->getBackendUser()->uc['pageTree_temporaryMountPoint'] = $pid; $this->getBackendUser()->writeUC(); $response = [ 'mountPointPath' => $this->getMountPointPath($pid), ]; return new JsonResponse($response); } /** * Converts nested tree structure produced by PageTreeRepository to a flat, one level array * and also adds visual representation information to the data. * * The result is intended to be used as JSON result - dumping data directly to HTML might lead to XSS! * * @param array $page * @param int $entryPoint * @param int $depth */ protected function pagesToFlatArray(array $page, int $entryPoint, int $depth = 0): array { $backendUser = $this->getBackendUser(); $pageId = (int)$page['uid']; if (in_array($pageId, $this->hiddenRecords, true)) { return []; } $stopPageTree = !empty($page['php_tree_stop']) && $depth > 0; $identifier = $entryPoint . '_' . $pageId; $suffix = ''; $prefix = ''; $nameSourceField = 'title'; $visibleText = $page['title']; $tooltip = BackendUtility::titleAttribForPages($page, '', false, $this->useNavTitle); if ($pageId !== 0) { $icon = $this->iconFactory->getIconForRecord('pages', $page, IconSize::SMALL); } else { $icon = $this->iconFactory->getIcon('apps-pagetree-root', IconSize::SMALL); } if ($this->useNavTitle && trim($page['nav_title'] ?? '') !== '') { $nameSourceField = 'nav_title'; $visibleText = $page['nav_title']; } if (trim($visibleText) === '') { $visibleText = htmlspecialchars('[' . $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.no_title') . ']'); } if ($this->addDomainName && ($page['is_siteroot'] ?? false)) { $domain = $this->getDomainNameForPage($pageId); $suffix = $domain !== '' ? ' [' . $domain . ']' : ''; } $lockInfo = BackendUtility::isRecordLocked('pages', $pageId); if (is_array($lockInfo)) { $tooltip .= ' - ' . $lockInfo['msg']; } if ($this->addIdAsPrefix) { $prefix = '[' . $pageId . '] '; } $labels = []; if (!empty($this->labels[$pageId . '.']) && isset($this->labels[$pageId . '.']['label']) && trim($this->labels[$pageId . '.']['label']) !== '') { $labels[] = new Label( label: $this->getLanguageService()->sL($this->labels[$pageId . '.']['label']), color: (string)($this->labels[$pageId . '.']['color'] ?? '#ff8700'), ); } $editable = false; if ($pageId !== 0) { $editable = $this->userHasAccessToModifyPagesAndToDefaultLanguage && $backendUser->doesUserHaveAccess($page, Permission::PAGE_EDIT); } $items = []; $item = [ // identifier is not only used for pages, therefore it's a string 'identifier' => (string)$pageId, 'parentIdentifier' => (string)($page['pid'] ?? ''), 'recordType' => 'pages', 'name' => $visibleText, 'prefix' => !empty($prefix) ? htmlspecialchars($prefix) : '', 'suffix' => !empty($suffix) ? htmlspecialchars($suffix) : '', 'tooltip' => $tooltip, 'depth' => $depth, 'icon' => $icon->getIdentifier(), 'overlayIcon' => $icon->getOverlayIcon() ? $icon->getOverlayIcon()->getIdentifier() : '', 'editable' => $editable, 'deletable' => $backendUser->doesUserHaveAccess($page, Permission::PAGE_DELETE), 'labels' => $labels, // _page is only for use in events so they do not need to fetch those // records again. The property will be removed from the final payload. '_page' => $page, // _translationLanguageUids contains the language UIDs for translations that matched (only populated during search) '_translationLanguageUids' => $this->pageTreeRepository->getTranslationMatches($pageId), 'doktype' => (int)($page['doktype'] ?? 0), 'nameSourceField' => $nameSourceField, 'mountPoint' => $entryPoint, 'workspaceId' => !empty($page['t3ver_oid']) ? $page['t3ver_oid'] : $pageId, ]; if (!empty($page['_children']) || $this->pageTreeRepository->hasChildren($pageId)) { $item['hasChildren'] = true; if ($depth >= $this->levelsToFetch) { $page = $this->pageTreeRepository->getTreeLevels($page, 1); } } if (is_array($lockInfo)) { $item['locked'] = true; } if ($stopPageTree) { $item['stopPageTree'] = true; } if ($depth === 0) { if ($this->showMountPathAboveMounts) { $item['note'] = $this->getMountPointPath($pageId); } } $items[] = $item; if (!$stopPageTree && is_array($page['_children']) && !empty($page['_children']) && ($depth < $this->levelsToFetch || $this->expandAllNodes)) { $items[key($items)]['loaded'] = true; foreach ($page['_children'] as $child) { $items = array_merge($items, $this->pagesToFlatArray($child, $entryPoint, $depth + 1)); } } return $items; } protected function initializePageTreeRepository(): PageTreeRepository { $backendUser = $this->getBackendUser(); $userTsConfig = $backendUser->getTSConfig(); $excludedDocumentTypes = GeneralUtility::intExplode(',', (string)($userTsConfig['options.']['pageTree.']['excludeDoktypes'] ?? ''), true); $additionalQueryRestrictions = []; if ($excludedDocumentTypes !== []) { $additionalQueryRestrictions[] = GeneralUtility::makeInstance(DocumentTypeExclusionRestriction::class, $excludedDocumentTypes); } $pageTreeRepository = GeneralUtility::makeInstance( PageTreeRepository::class, $backendUser->workspace, [], $additionalQueryRestrictions ); $pageTreeRepository->setAdditionalWhereClause($backendUser->getPagePermsClause(Permission::PAGE_SHOW)); return $pageTreeRepository; } /** * Fetches all pages for all tree entry points the user is allowed to see * * @param string $query The search query can either be a string to be found in the title or the nav_title of a page or the uid of a page. */ protected function getAllEntryPointPageTrees(int $startPid = 0, string $query = ''): array { $this->pageTreeRepository ??= $this->initializePageTreeRepository(); $backendUser = $this->getBackendUser(); if ($startPid === 0) { $startPid = (int)($backendUser->uc['pageTree_temporaryMountPoint'] ?? 0); } $entryPointIds = null; if ($startPid > 0) { $entryPointIds = [$startPid]; } elseif (!empty($this->alternativeEntryPoints)) { $entryPointIds = $this->alternativeEntryPoints; } $permClause = $backendUser->getPagePermsClause(Permission::PAGE_SHOW); if ($query !== '') { $this->levelsToFetch = 999; $this->pageTreeRepository->fetchFilteredTree( $query, $this->getAllowedMountPoints(), $permClause ); } $rootRecord = [ 'uid' => 0, 'title' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?: 'TYPO3', ]; $entryPointRecords = []; $mountPoints = []; if ($entryPointIds === null) { //watch out for deleted pages returned as webmount $mountPoints = $backendUser->getWebmounts(); $mountPoints = array_filter($mountPoints, fn(int $id): bool => !in_array($id, $this->hiddenRecords, true)); // Switch to multiple-entryPoint-mode if the rootPage is to be mounted. // (other mounts would appear duplicated in the pid = 0 tree otherwise) if (in_array(0, $mountPoints, true)) { $entryPointIds = $mountPoints; } } if ($entryPointIds === null) { if ($query !== '') { $rootRecord = $this->pageTreeRepository->getTree(0, null, $mountPoints); } else { $rootRecord = $this->pageTreeRepository->getTreeLevels($rootRecord, $this->levelsToFetch, $mountPoints); } $mountPointOrdering = array_flip($mountPoints); if (isset($rootRecord['_children'])) { usort($rootRecord['_children'], static function ($a, $b) use ($mountPointOrdering) { return ($mountPointOrdering[$a['uid']] ?? 0) <=> ($mountPointOrdering[$b['uid']] ?? 0); }); } $entryPointRecords[] = $rootRecord; } else { $entryPointIds = array_filter($entryPointIds, fn(int $id): bool => !in_array($id, $this->hiddenRecords, true)); foreach ($entryPointIds as $k => $entryPointId) { if ($entryPointId === 0) { $entryPointRecord = $rootRecord; } else { $entryPointRecord = BackendUtility::getRecordWSOL('pages', $entryPointId, '*', $permClause); if ($entryPointRecord !== null && !$backendUser->isInWebMount($entryPointId)) { $entryPointRecord = null; } if ($entryPointRecord === null) { continue; } } $entryPointRecord['uid'] = (int)$entryPointRecord['uid']; if ($query === '') { $entryPointRecord = $this->pageTreeRepository->getTreeLevels($entryPointRecord, $this->levelsToFetch); } else { $entryPointRecord = $this->pageTreeRepository->getTree($entryPointRecord['uid'], null, $entryPointIds); } if ($entryPointRecord !== []) { $entryPointRecords[$k] = $entryPointRecord; } } } return $entryPointRecords; } /** * Returns the first configured domain name for a page */ protected function getDomainNameForPage(int $pageId): string { try { $site = $this->siteFinder->getSiteByRootPageId($pageId); return (string)$site->getBase(); } catch (SiteNotFoundException) { // No site found } return ''; } /** * Returns the mount point path for a temporary mount or the given id */ protected function getMountPointPath(int $uid): string { if ($uid <= 0) { return ''; } $rootline = array_reverse(BackendUtility::BEgetRootLine($uid)); array_shift($rootline); $path = []; foreach ($rootline as $rootlineElement) { $record = BackendUtility::getRecordWSOL('pages', $rootlineElement['uid'], 'title, nav_title', '', true, true); $text = $record['title']; if ($this->useNavTitle && trim($record['nav_title'] ?? '') !== '') { $text = $record['nav_title']; } $path[] = htmlspecialchars($text); } return '/' . implode('/', $path); } /** * Check if drag-move in the svg tree is allowed for the user */ protected function isDragMoveAllowed(): bool { $backendUser = $this->getBackendUser(); return $backendUser->isAdmin() || ($backendUser->check('tables_modify', 'pages') && $backendUser->checkLanguageAccess(0)); } /** * Get allowed mountpoints. Returns temporary mountpoint when temporary mountpoint is used. * * @return int[] */ protected function getAllowedMountPoints(): array { $mountPoints = (int)($this->getBackendUser()->uc['pageTree_temporaryMountPoint'] ?? 0); if (!$mountPoints) { if (!empty($this->alternativeEntryPoints)) { return $this->alternativeEntryPoints; } return $this->getBackendUser()->getWebmounts(); } return [$mountPoints]; } protected function getPostProcessedPageItems(ServerRequestInterface $request, array $items): array { return array_map( static function (array $item): PageTreeItem { return new PageTreeItem( // TreeItem new TreeItem( identifier: $item['identifier'], parentIdentifier: (string)($item['parentIdentifier'] ?? ''), recordType: (string)($item['recordType'] ?? ''), name: (string)($item['name'] ?? ''), note: (string)($item['note'] ?? ''), prefix: (string)($item['prefix'] ?? ''), suffix: (string)($item['suffix'] ?? ''), tooltip: (string)($item['tooltip'] ?? ''), depth: (int)($item['depth'] ?? 0), hasChildren: (bool)($item['hasChildren'] ?? false), loaded: (bool)($item['loaded'] ?? false), editable: (bool)($item['editable'] ?? false), deletable: (bool)($item['deletable'] ?? false), icon: (string)($item['icon'] ?? ''), overlayIcon: (string)($item['overlayIcon'] ?? ''), statusInformation: (array)($item['statusInformation'] ?? []), labels: (array)($item['labels'] ?? []), ), // PageTreeItem doktype: (int)($item['doktype'] ?? ''), nameSourceField: (string)($item['nameSourceField'] ?? ''), workspaceId: (int)($item['workspaceId'] ?? 0), locked: (bool)($item['locked'] ?? false), stopPageTree: (bool)($item['stopPageTree'] ?? false), mountPoint: (int)($item['mountPoint'] ?? 0), ); }, $this->eventDispatcher->dispatch( new AfterPageTreeItemsPreparedEvent($request, $items) )->getItems() ); } protected function getBackendUser(): BackendUserAuthentication { return $GLOBALS['BE_USER']; } protected function getLanguageService(): ?LanguageService { return $GLOBALS['LANG'] ?? null; } }