context = $context; $this->site = $site; $this->enhancerFactory = $enhancerFactory ?? GeneralUtility::makeInstance(EnhancerFactory::class); } /** * Fetches an array of possible URLs that match the current site + language (incl. fallbacks) * * @return array> */ public function getCandidatesForPath(string $urlPath, SiteLanguage $language): array { $slugCandidates = $this->getCandidateSlugsFromRoutePath($urlPath ?: '/'); $pageCandidates = []; $languages = [$language->getLanguageId()]; if (!empty($language->getFallbackLanguageIds())) { $languages = array_merge($languages, $language->getFallbackLanguageIds()); } // Iterate all defined languages in their configured order to get matching page candidates somewhere in the language fallback chain foreach ($languages as $languageId) { $pageCandidatesFromSlugsAndLanguage = $this->getPagesFromDatabaseForCandidates($slugCandidates, $languageId); // Determine whether fetched page candidates qualify for the request. The incoming URL is checked against all // pages found for the current URL and language. foreach ($pageCandidatesFromSlugsAndLanguage as $candidate) { $slugCandidate = '/' . trim($candidate['slug'], '/'); if ($slugCandidate === '/' || str_starts_with($urlPath, $slugCandidate)) { // The slug is a subpart of the requested URL, so it's a possible candidate if ($urlPath === $slugCandidate) { // The requested URL matches exactly the found slug. We can't find a better match, // so use that page candidate and stop any further querying. $pageCandidates = [$candidate]; break 2; } $pageCandidates[] = $candidate; } } } return $pageCandidates; } /** * Fetches the page without any language or other hidden/enable fields, but only takes * "deleted" and "workspace" into account, as all other things will be evaluated later. * * This is only needed for resolving the ACTUAL Page Id when index.php?id=13 was given * * Should be rebuilt to return the actual Page ID considering the online ID of the page. * * @param int $pageId */ public function getRealPageIdForPageIdAsPossibleCandidate(int $pageId): ?int { $workspaceId = (int)$this->context->getPropertyFromAspect('workspace', 'id'); $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('pages'); $queryBuilder ->getRestrictions() ->removeAll() ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $workspaceId)); $statement = $queryBuilder ->select('uid', 'l10n_parent') ->from('pages') ->where( $queryBuilder->expr()->eq( 'uid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT) ) ) ->executeQuery(); $page = $statement->fetchAssociative(); if (empty($page)) { return null; } return (int)($page['l10n_parent'] ?: $page['uid']); } /** * Gets all patterns that can be used to redecorate (undecorate) a * potential previously decorated route path. * * @return string regular expression pattern capable of redecorating */ protected function getRoutePathRedecorationPattern(): string { $decoratingEnhancers = $this->getDecoratingEnhancers(); if (empty($decoratingEnhancers)) { return ''; } $redecorationPatterns = array_map( static function (DecoratingEnhancerInterface $decorationEnhancers) { $pattern = $decorationEnhancers->getRoutePathRedecorationPattern(); return '(?:' . $pattern . ')'; }, $decoratingEnhancers ); return '(?P' . implode('|', $redecorationPatterns) . ')'; } /** * Resolves decorating enhancers without having aspects assigned. These * instances are used to pre-process URL path and MUST NOT be used for * actually resolving or generating URL parameters. * * @return DecoratingEnhancerInterface[] */ protected function getDecoratingEnhancers(): array { $enhancers = []; foreach ($this->site->getConfiguration()['routeEnhancers'] ?? [] as $enhancerConfiguration) { $enhancerType = $enhancerConfiguration['type'] ?? ''; $enhancer = $this->enhancerFactory->create($enhancerType, $enhancerConfiguration); if ($enhancer instanceof DecoratingEnhancerInterface) { $enhancers[] = $enhancer; } } return $enhancers; } /** * Check for records in the database which matches one of the slug candidates. * * @param array $excludeUids when called recursively this is the mountpoint parameter of the original prefix * @return array[]|array * @throws SiteNotFoundException */ protected function getPagesFromDatabaseForCandidates(array $slugCandidates, int $languageId, array $excludeUids = []): array { $workspaceId = (int)$this->context->getPropertyFromAspect('workspace', 'id'); $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('pages'); $queryBuilder ->getRestrictions() ->removeAll() ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $workspaceId, true)); $statement = $queryBuilder ->select('*') ->from('pages') ->where( $queryBuilder->expr()->eq( 'sys_language_uid', $queryBuilder->createNamedParameter($languageId, Connection::PARAM_INT) ), $queryBuilder->expr()->in( 'slug', $queryBuilder->createNamedParameter( $slugCandidates, Connection::PARAM_STR_ARRAY ) ) ) // Exact match will be first, that's important ->orderBy('slug', 'desc') // versioned records should be rendered before the live records ->addOrderBy('t3ver_wsid', 'desc') // Sort pages that are not MountPoint pages before mount points ->addOrderBy('mount_pid_ol', 'asc') ->addOrderBy('mount_pid', 'asc') ->executeQuery(); $pages = []; $siteFinder = GeneralUtility::makeInstance(SiteFinder::class); $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $this->context); $isRecursiveCall = !empty($excludeUids); while ($row = $statement->fetchAssociative()) { $mountPageInformation = null; $pageIdInDefaultLanguage = (int)($languageId > 0 ? $row['l10n_parent'] : ($row['t3ver_oid'] ?: $row['uid'])); // When this page was added before via recursion, this page should be skipped if (in_array($pageIdInDefaultLanguage, $excludeUids, true)) { continue; } try { $isOnSameSite = $siteFinder->getSiteByPageId($pageIdInDefaultLanguage)->getRootPageId() === $this->site->getRootPageId(); } catch (SiteNotFoundException $e) { // Page is not in a site, so it's not considered $isOnSameSite = false; } // If a MountPoint is found on the current site, and it hasn't been added yet by some other iteration // (see below "findPageCandidatesOfMountPoint"), then let's resolve the MountPoint information now if (!$isOnSameSite && $isRecursiveCall) { // Not in the same site, and called recursive, should be skipped continue; } $mountPageInformation = $pageRepository->getMountPointInfo($pageIdInDefaultLanguage, $row); // Mount Point Pages which are not on the same site (when not called on the first level) should be skipped // As they just clutter up the queries. if (!$isOnSameSite && !$isRecursiveCall && $mountPageInformation) { continue; } $mountedPage = null; if (is_array($mountPageInformation)) { // Add the MPvar to the row, so it can be used later-on in the PageRouter / PageArguments $row['MPvar'] = $mountPageInformation['MPvar']; $mountedPage = $pageRepository->getPage_noCheck((int)$mountPageInformation['mount_pid_rec']['uid']); // Ensure to fetch the slug in the translated page $mountedPage = $pageRepository->getLanguageOverlay('pages', $mountedPage, new LanguageAspect($languageId, $languageId)); // Mount wasn't connected properly, so it is skipped if (!$mountedPage) { continue; } // If the page is a MountPoint which should be overlaid with the contents of the mounted page, // it must never be accessible directly, but only in the MountPoint context. Therefore we change // the current ID and slug. // This needs to happen before the regular case, as the $pageToAdd contains the MPvar information if ((int)$row['doktype'] === PageRepository::DOKTYPE_MOUNTPOINT && $row['mount_pid_ol']) { // If the mounted page was already added from above, this should not be added again (to include // the mount point parameter). if (in_array((int)$mountedPage['uid'], $excludeUids, true)) { continue; } $pageToAdd = $mountedPage; // Make sure target page "/about-us" is replaced by "/global-site/about-us" so router works $pageToAdd['MPvar'] = $mountPageInformation['MPvar']; $pageToAdd['slug'] = $row['slug']; $pages[] = $pageToAdd; $excludeUids[] = (int)$pageToAdd['uid']; $excludeUids[] = $pageIdInDefaultLanguage; } } // This is the regular "non-MountPoint page" case (must happen after the if condition so MountPoint // pages that have been replaced by the Mounted Page will not be added again. if ($isOnSameSite && !in_array($pageIdInDefaultLanguage, $excludeUids, true)) { $pages[] = $row; $excludeUids[] = $pageIdInDefaultLanguage; } // Add possible sub-pages prepended with the MountPoint page slug if (is_array($mountPageInformation)) { /** @var array $mountedPage */ $siteOfMountedPage = $siteFinder->getSiteByPageId((int)$mountedPage['uid']); $morePageCandidates = $this->findPageCandidatesOfMountPoint( $row, $mountedPage, $siteOfMountedPage, $languageId, $slugCandidates ); foreach ($morePageCandidates as $candidate) { // When called previously this MountPoint page should be skipped if (in_array((int)$candidate['uid'], $excludeUids, true)) { continue; } $pages[] = $candidate; } } } return $pages; } /** * Check if the page candidate is a mount point, if so, we need to * re-start the slug candidates procedure with the mount point as a prefix (= context of the subpage). * * Before doing the slugCandidates are adapted to remove the slug of the mount point (actively moving the pointer * of the path to strip away the existing prefix), then checking for more pages. * * Once possible candidates are found, the slug prefix needs to be re-added so the PageRouter finds the page, * with an additional 'MPvar' attribute. * However, all page candidates needs to be checked if they are connected in the proper mount page. * * @param array $mountPointPage the page with doktype=7 * @param array $mountedPage the target page where the mountpoint is pointing to * @param Site $siteOfMountedPage the site of the target page, which could be different from the current page * @param int $languageId the current language id * @param array $slugCandidates the existing slug candidates that were looked for previously * @return array more candidates */ protected function findPageCandidatesOfMountPoint( array $mountPointPage, array $mountedPage, Site $siteOfMountedPage, int $languageId, array $slugCandidates ): array { $pages = []; $slugOfMountPoint = $mountPointPage['slug'] ?? ''; $commonSlugPrefixOfMountedPage = rtrim($mountedPage['slug'] ?? '', '/'); $narrowedDownSlugPrefixes = []; foreach ($slugCandidates as $slugCandidate) { // Remove the mount point prefix (that we just found) from the slug candidates if (str_starts_with($slugCandidate, $slugOfMountPoint)) { // Find pages without the common prefix $narrowedDownSlugPrefix = '/' . trim(substr($slugCandidate, strlen($slugOfMountPoint)), '/'); $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix; $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix . '/'; // Find pages with the prefix of the mounted page as well if ($commonSlugPrefixOfMountedPage) { $narrowedDownSlugPrefix = $commonSlugPrefixOfMountedPage . $narrowedDownSlugPrefix; $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix; $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix . '/'; } } } $trimmedSlugPrefixes = []; $narrowedDownSlugPrefixes = array_unique($narrowedDownSlugPrefixes); foreach ($narrowedDownSlugPrefixes as $narrowedDownSlugPrefix) { $narrowedDownSlugPrefix = trim($narrowedDownSlugPrefix, '/'); $trimmedSlugPrefixes[] = '/' . $narrowedDownSlugPrefix; if (!empty($narrowedDownSlugPrefix)) { $trimmedSlugPrefixes[] = '/' . $narrowedDownSlugPrefix . '/'; } } $trimmedSlugPrefixes = array_unique($trimmedSlugPrefixes); rsort($trimmedSlugPrefixes); $slugProviderForMountPage = GeneralUtility::makeInstance(static::class, $this->context, $siteOfMountedPage, $this->enhancerFactory); // Find the right pages for which have been matched $excludedPageIds = [(int)$mountPointPage['uid']]; $pageCandidates = $slugProviderForMountPage->getPagesFromDatabaseForCandidates( $trimmedSlugPrefixes, $languageId, $excludedPageIds ); // The rootline is built with page IDs of the default language, so the mount point page ID // of the default language must be used for the connection check below $mountPointPageIdInDefaultLanguage = (int)($languageId > 0 ? $mountPointPage['l10n_parent'] : ($mountPointPage['t3ver_oid'] ?: $mountPointPage['uid'])); // Depending on the "mount_pid_ol" parameter, the mountedPage or the mounted page is in the rootline $pageWhichMustBeInRootLine = (int)($mountPointPage['mount_pid_ol'] ? $mountedPage['uid'] : $mountPointPageIdInDefaultLanguage); foreach ($pageCandidates as $pageCandidate) { if (!$pageCandidate['mount_pid_ol']) { $pageCandidate['MPvar'] = !empty($pageCandidate['MPvar']) ? $mountPointPage['MPvar'] . ',' . $pageCandidate['MPvar'] : $mountPointPage['MPvar']; } // In order to avoid the possibility that any random page like /about-us which is not connected to the mount // point is not possible to be called via /my-mount-point/about-us, let's check the $pageCandidateIsConnectedInMountPoint = false; $rootLine = GeneralUtility::makeInstance( RootlineUtility::class, $pageCandidate['uid'], (string)($pageCandidate['MPvar'] ?? $pageCandidate['mount_pid_ol']), $this->context )->get(); foreach ($rootLine as $pageInRootLine) { if ((int)$pageInRootLine['uid'] === $pageWhichMustBeInRootLine) { $pageCandidateIsConnectedInMountPoint = true; break; } } if ($pageCandidateIsConnectedInMountPoint === false) { continue; } // Rewrite the slug of the subpage to match the PageRouter matching again // This is done by first removing the "common" prefix possibly provided by the Mounted Page // But more importantly adding the $slugOfMountPoint of the MountPoint Page $slugOfSubpage = $pageCandidate['slug']; if ($commonSlugPrefixOfMountedPage && str_starts_with($slugOfSubpage, $commonSlugPrefixOfMountedPage)) { $slugOfSubpage = substr($slugOfSubpage, strlen($commonSlugPrefixOfMountedPage)); } $pageCandidate['slug'] = $slugOfMountPoint . (($slugOfSubpage && $slugOfSubpage !== '/') ? '/' . trim($slugOfSubpage, '/') : ''); $pages[] = $pageCandidate; } return $pages; } /** * Returns possible URL parts for a string like /home/about-us/offices/ or /home/about-us/offices.json * to return. * * /home/about-us/offices/ * /home/about-us/offices.json * /home/about-us/offices * /home/about-us/ * /home/about-us * /home/ * /home * / * * @param string $routePath * @return string[] */ protected function getCandidateSlugsFromRoutePath(string $routePath): array { $redecorationPattern = $this->getRoutePathRedecorationPattern(); if (!empty($redecorationPattern) && preg_match('#' . $redecorationPattern . '#', $routePath, $matches)) { $decoration = $matches['decoration']; $decorationPattern = preg_quote($decoration, '#'); $routePath = preg_replace('#' . $decorationPattern . '$#', '', $routePath) ?? ''; } $candidatePathParts = []; $pathParts = GeneralUtility::trimExplode('/', $routePath, true); if (empty($pathParts)) { return ['/']; } while (!empty($pathParts)) { $prefix = '/' . implode('/', $pathParts); $candidatePathParts[] = $prefix . '/'; $candidatePathParts[] = $prefix; array_pop($pathParts); } $candidatePathParts[] = '/'; return $candidatePathParts; } }