457 lines
21 KiB
PHP
457 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* This file is part of the TYPO3 CMS project.
|
|
*
|
|
* It is free software; you can redistribute it and/or modify it under
|
|
* the terms of the GNU General Public License, either version 2
|
|
* of the License, or any later version.
|
|
*
|
|
* For the full copyright and license information, please read the
|
|
* LICENSE.txt file that was distributed with this source code.
|
|
*
|
|
* The TYPO3 project - inspiring people to share!
|
|
*/
|
|
|
|
namespace TYPO3\CMS\Core\Routing;
|
|
|
|
use TYPO3\CMS\Core\Context\Context;
|
|
use TYPO3\CMS\Core\Context\LanguageAspect;
|
|
use TYPO3\CMS\Core\Database\Connection;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
|
|
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
|
|
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
|
|
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
|
|
use TYPO3\CMS\Core\Routing\Enhancer\DecoratingEnhancerInterface;
|
|
use TYPO3\CMS\Core\Routing\Enhancer\EnhancerFactory;
|
|
use TYPO3\CMS\Core\Site\Entity\Site;
|
|
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
|
|
use TYPO3\CMS\Core\Site\SiteFinder;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
use TYPO3\CMS\Core\Utility\RootlineUtility;
|
|
|
|
/**
|
|
* Provides possible pages (from the database) that _could_ match a certain URL path,
|
|
* but also works for fetching the best "slug" value for multi-lingual pages with a specific language requested.
|
|
*
|
|
* @internal as this API might change and a possible interface is given at some point.
|
|
*/
|
|
class PageSlugCandidateProvider
|
|
{
|
|
protected Site $site;
|
|
protected Context $context;
|
|
protected EnhancerFactory $enhancerFactory;
|
|
|
|
public function __construct(Context $context, Site $site, ?EnhancerFactory $enhancerFactory)
|
|
{
|
|
$this->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<int,array<string,mixed>>
|
|
*/
|
|
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<decoration>' . 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;
|
|
}
|
|
}
|