| Current Path : /var/www/surf/TYPO3/vendor/typo3/cms-core/Classes/Routing/ |
| Current File : /var/www/surf/TYPO3/vendor/typo3/cms-core/Classes/Routing/PageRouter.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 Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\Routing\Exception\MissingMandatoryParametersException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\LanguageAspectFactory;
use TYPO3\CMS\Core\Domain\Page;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\Routing\Aspect\AspectFactory;
use TYPO3\CMS\Core\Routing\Aspect\MappableProcessor;
use TYPO3\CMS\Core\Routing\Aspect\StaticMappableAspectInterface;
use TYPO3\CMS\Core\Routing\Enhancer\DecoratingEnhancerInterface;
use TYPO3\CMS\Core\Routing\Enhancer\EnhancerFactory;
use TYPO3\CMS\Core\Routing\Enhancer\EnhancerInterface;
use TYPO3\CMS\Core\Routing\Enhancer\InflatableEnhancerInterface;
use TYPO3\CMS\Core\Routing\Enhancer\ResultingInterface;
use TYPO3\CMS\Core\Routing\Enhancer\RoutingEnhancerInterface;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Page\CacheHashCalculator;
/**
* Page Router - responsible for a page based on a request, by looking up the slug of the page path.
* Is also used for generating URLs for pages.
*
* Resolving is done via the "Route Candidate" pattern.
*
* Example:
* - /about-us/team/management/
*
* will look for all pages that have
* - /about-us
* - /about-us/
* - /about-us/team
* - /about-us/team/
* - /about-us/team/management
* - /about-us/team/management/
*
* And create route candidates for that.
*
* Please note: PageRouter does not restrict the HTTP method or is bound to any domain constraints,
* as the SiteMatcher has done that already.
*
* The concept of the PageRouter is to *resolve*, and to *generate* URIs. On top, it is a facade to hide the
* dependency to symfony and to not expose its logic.
*/
class PageRouter implements RouterInterface
{
protected Site $site;
protected EnhancerFactory $enhancerFactory;
protected AspectFactory $aspectFactory;
protected CacheHashCalculator $cacheHashCalculator;
protected Context $context;
protected RequestContextFactory $requestContextFactory;
/**
* A page router is always bound to a specific site.
*/
public function __construct(Site $site, Context $context = null)
{
$this->site = $site;
$this->context = $context ?? GeneralUtility::makeInstance(Context::class);
$this->enhancerFactory = GeneralUtility::makeInstance(EnhancerFactory::class);
$this->aspectFactory = GeneralUtility::makeInstance(AspectFactory::class, $this->context);
$this->cacheHashCalculator = GeneralUtility::makeInstance(CacheHashCalculator::class);
$this->requestContextFactory = GeneralUtility::makeInstance(RequestContextFactory::class);
}
/**
* Finds a RouteResult based on the given request.
*
* @param RouteResultInterface|SiteRouteResult|null $previousResult
* @return RouteResultInterface|PageArguments
* @throws RouteNotFoundException
*/
public function matchRequest(ServerRequestInterface $request, RouteResultInterface $previousResult = null): RouteResultInterface
{
if ($previousResult === null) {
throw new RouteNotFoundException('No previous result given. Cannot find a page for an empty route part', 1555303496);
}
$candidateProvider = $this->getSlugCandidateProvider($this->context);
// Legacy URIs (?id=12345) takes precedence, no matter if a route is given
$requestId = (int)($request->getQueryParams()['id'] ?? 0);
if ($requestId > 0) {
if (!empty($pageId = $candidateProvider->getRealPageIdForPageIdAsPossibleCandidate($requestId))) {
return new PageArguments(
(int)$pageId,
(string)($request->getQueryParams()['type'] ?? '0'),
[],
[],
$request->getQueryParams()
);
}
throw new RouteNotFoundException('The requested page does not exist.', 1557839801);
}
$urlPath = $previousResult->getTail();
$language = $previousResult->getLanguage();
// Keep possible existing "/" at the end (no trim, just ltrim), even though the page slug might not
// contain a "/" at the end. This way we find page candidates where pages MIGHT have a trailing slash
// and pages with slugs that do not have a trailing slash
// $pageCandidates will contain more records than expected, which is important here, as the ->match() method
// will handle this then.
// The prepended slash will ensure that the root page of the site tree will also be fetched
$prefixedUrlPath = '/' . ltrim($urlPath, '/');
$pageCandidates = $candidateProvider->getCandidatesForPath($prefixedUrlPath, $language);
// Stop if there are no candidates
if (empty($pageCandidates)) {
throw new RouteNotFoundException('No page candidates found for path "' . $prefixedUrlPath . '"', 1538389999);
}
/** @var RouteCollection<string, Route> $fullCollection */
$fullCollection = new RouteCollection();
foreach ($pageCandidates ?? [] as $page) {
$pageIdForDefaultLanguage = (int)($page['l10n_parent'] ?: $page['uid']);
$pagePath = $page['slug'];
$pageCollection = new RouteCollection();
$defaultRouteForPage = new Route(
$pagePath,
[],
[],
['utf8' => true, '_page' => $page]
);
$pageCollection->add('default', $defaultRouteForPage);
$enhancers = $this->getEnhancersForPage($pageIdForDefaultLanguage, $language);
foreach ($enhancers as $enhancer) {
if ($enhancer instanceof DecoratingEnhancerInterface) {
$enhancer->decorateForMatching($pageCollection, $urlPath);
}
}
foreach ($enhancers as $enhancer) {
if ($enhancer instanceof RoutingEnhancerInterface) {
$enhancer->enhanceForMatching($pageCollection);
}
}
$collectionPrefix = 'page_' . $page['uid'];
// Pages with a MountPoint Parameter means that they have a different context, and should be treated
// as a separate instance
if (isset($page['MPvar'])) {
$collectionPrefix .= '_MP_' . str_replace(',', '', $page['MPvar']);
}
$pageCollection->addNamePrefix($collectionPrefix . '_');
$fullCollection->addCollection($pageCollection);
// set default route flag after all routes have been processed
$defaultRouteForPage->setOption('_isDefault', true);
}
$matcher = new PageUriMatcher($fullCollection);
try {
$result = $matcher->match($prefixedUrlPath);
/** @var Route $matchedRoute */
$matchedRoute = $fullCollection->get($result['_route']);
// Only use route if page language variant matches current language, otherwise handle it as route not found.
if ($this->isRouteReallyValidForLanguage($matchedRoute, $language)) {
return $this->buildPageArguments($matchedRoute, $result, $request->getQueryParams());
}
} catch (ResourceNotFoundException $e) {
if (str_ends_with($prefixedUrlPath, '/')) {
// Second try, look for /my-page even though the request was called via /my-page/ and the slash
// was not part of the slug, but let's then check again
try {
$result = $matcher->match(rtrim($prefixedUrlPath, '/'));
/** @var Route $matchedRoute */
$matchedRoute = $fullCollection->get($result['_route']);
// Only use route if page language variant matches current language, otherwise
// handle it as route not found.
if ($this->isRouteReallyValidForLanguage($matchedRoute, $language)) {
return $this->buildPageArguments($matchedRoute, $result, $request->getQueryParams());
}
} catch (ResourceNotFoundException $e) {
// Do nothing
}
} else {
// Second try, look for /my-page/ even though the request was called via /my-page and the slash
// was part of the slug, but let's then check again
try {
$result = $matcher->match($prefixedUrlPath . '/');
/** @var Route $matchedRoute */
$matchedRoute = $fullCollection->get($result['_route']);
// Only use route if page language variant matches current language, otherwise
// handle it as route not found.
if ($this->isRouteReallyValidForLanguage($matchedRoute, $language)) {
return $this->buildPageArguments($matchedRoute, $result, $request->getQueryParams());
}
} catch (ResourceNotFoundException $e) {
// Do nothing
}
}
}
throw new RouteNotFoundException('No route found for path "' . $urlPath . '"', 1538389998);
}
/**
* API for generating a page uri where the $route parameter is typically an array (a page record) or the page ID
*
* @param array|string|int|Page $route
* @param array $parameters an array of query parameters which can be built into the URI path, also consider the special handling of "_language"
* @param string $fragment additional #my-fragment part
* @param string $type see the RouterInterface for possible types
* @throws InvalidRouteArgumentsException
*/
public function generateUri($route, array $parameters = [], string $fragment = '', string $type = ''): UriInterface
{
// Resolve language
$language = null;
$languageOption = $parameters['_language'] ?? null;
unset($parameters['_language']);
if ($languageOption instanceof SiteLanguage) {
$language = $languageOption;
} elseif ($languageOption !== null) {
$language = $this->site->getLanguageById((int)$languageOption);
}
if ($language === null) {
$language = $this->site->getDefaultLanguage();
}
$pageId = 0;
if ($route instanceof Page) {
$pageId = $route->getPageId();
} elseif (is_array($route)) {
$pageId = (int)$route['uid'];
} elseif (is_scalar($route)) {
$pageId = (int)$route;
}
$context = clone $this->context;
$context->setAspect('language', LanguageAspectFactory::createFromSiteLanguage($language));
$pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context);
if ($route instanceof Page) {
$page = $route->toArray();
} elseif (is_array($route)
// Check 3rd party input $route for basic requirements
&& isset($route['uid'], $route['sys_language_uid'], $route['l10n_parent'], $route['slug'])
&& (int)$route['sys_language_uid'] === $language->getLanguageId()
&& ((int)$route['l10n_parent'] === 0 || ($route['_PAGES_OVERLAY'] ?? false))
) {
$page = $route;
} else {
$page = $pageRepository->getPage($pageId, true);
}
$pagePath = $page['slug'] ?? '';
if ($parameters['MP'] ?? '') {
$mountPointPairs = explode(',', $parameters['MP']);
$pagePath = $this->resolveMountPointParameterIntoPageSlug(
$pageId,
$pagePath,
$mountPointPairs,
$pageRepository
);
// If the MountPoint page has a different site, the link needs to be generated
// with the base of the MountPoint page, this is especially relevant for cross-domain linking
// Because the language contains the full base, it is retrieved in this case.
try {
[, $mountPointPage] = explode('-', (string)reset($mountPointPairs));
$site = GeneralUtility::makeInstance(SiteMatcher::class)
->matchByPageId((int)$mountPointPage);
$language = $site->getLanguageById($language->getLanguageId());
} catch (SiteNotFoundException $e) {
// No alternative site found, use the existing one
}
// Store the MP parameter in the page record, so it could be used for any enhancers
$page['MPvar'] = $parameters['MP'];
unset($parameters['MP']);
}
$originalParameters = $parameters;
$collection = new RouteCollection();
$defaultRouteForPage = new Route(
'/' . ltrim($pagePath, '/'),
[],
[],
['utf8' => true, '_page' => $page]
);
$collection->add('default', $defaultRouteForPage);
// cHash is never considered because cHash is built by this very method.
unset($originalParameters['cHash']);
$enhancers = $this->getEnhancersForPage($pageId, $language);
foreach ($enhancers as $enhancer) {
if ($enhancer instanceof RoutingEnhancerInterface) {
$enhancer->enhanceForGeneration($collection, $originalParameters);
}
}
foreach ($enhancers as $enhancer) {
if ($enhancer instanceof DecoratingEnhancerInterface) {
$enhancer->decorateForGeneration($collection, $originalParameters);
}
}
$mappableProcessor = new MappableProcessor();
$requestContext = $this->requestContextFactory->fromSiteLanguage($language);
$generator = new UrlGenerator($collection, $requestContext);
$generator->injectMappableProcessor($mappableProcessor);
// set default route flag after all routes have been processed
$defaultRouteForPage->setOption('_isDefault', true);
$allRoutes = GeneralUtility::makeInstance(RouteSorter::class)
->withRoutes($collection->all())
->withOriginalParameters($originalParameters)
->sortRoutesForGeneration()
->getRoutes();
$matchedRoute = null;
$pageRouteResult = null;
$uri = null;
// map our reference type to symfony's custom paths
$referenceType = $type === static::ABSOLUTE_PATH ? UrlGenerator::ABSOLUTE_PATH : UrlGenerator::ABSOLUTE_URL;
/**
* @var string $routeName
* @var Route $route
*/
foreach ($allRoutes as $routeName => $route) {
try {
$parameters = $originalParameters;
if ($route->hasOption('deflatedParameters')) {
$parameters = $route->getOption('deflatedParameters');
}
$mappableProcessor->generate($route, $parameters);
// ABSOLUTE_URL is used as default fallback
$urlAsString = $generator->generate($routeName, $parameters, $referenceType);
$uri = new Uri($urlAsString);
/** @var Route $matchedRoute */
$matchedRoute = $collection->get($routeName);
// fetch potential applied defaults for later cHash generation
// (even if not applied in route, it will be exposed during resolving)
$appliedDefaults = $matchedRoute->getOption('_appliedDefaults') ?? [];
parse_str($uri->getQuery(), $remainingQueryParameters);
$enhancer = $route->getEnhancer();
if ($enhancer instanceof InflatableEnhancerInterface) {
$remainingQueryParameters = $enhancer->inflateParameters($remainingQueryParameters);
}
$pageRouteResult = $this->buildPageArguments($route, array_merge($appliedDefaults, $parameters), $remainingQueryParameters);
break;
} catch (MissingMandatoryParametersException $e) {
// no match
}
}
if (!$uri instanceof UriInterface) {
throw new InvalidRouteArgumentsException('Uri could not be built for page "' . $pageId . '"', 1538390230);
}
if ($pageRouteResult && $pageRouteResult->areDirty()) {
// for generating URLs this should(!) never happen
// if it does happen, generator logic has flaws
throw new InvalidRouteArgumentsException('Route arguments are dirty', 1537613247);
}
if ($matchedRoute && $pageRouteResult && !empty($pageRouteResult->getDynamicArguments())) {
$cacheHash = $this->generateCacheHash($pageId, $pageRouteResult);
$queryArguments = $pageRouteResult->getQueryArguments();
if (!empty($cacheHash)) {
$queryArguments['cHash'] = $cacheHash;
}
$uri = $uri->withQuery(http_build_query($queryArguments, '', '&', PHP_QUERY_RFC3986));
}
if ($fragment) {
$uri = $uri->withFragment($fragment);
}
return $uri;
}
/**
* When a MP parameter is given, the mount point parameter is resolved, and the slug of the new page
* is added while the same parts of the original pagePath is removed (before).
* This way, the subpage to a mounted page has now a different "base" (= prefixed with the slug of the
* mount point).
*
* This is done recursively when multiple mount point parameter pairs
*
* @param int $pageId
* @param string $pagePath the original path of the page
* @param array $mountPointPairs an array with MP pairs (like ['13-3', '4-2'] for recursive mount points)
* @param PageRepository $pageRepository
*/
protected function resolveMountPointParameterIntoPageSlug(
int $pageId,
string $pagePath,
array $mountPointPairs,
PageRepository $pageRepository
): string {
// Handle recursive mount points
$prefixesToRemove = [];
$slugPrefixesToAdd = [];
foreach ($mountPointPairs as $mountPointPair) {
[$mountRoot, $mountedPage] = GeneralUtility::intExplode('-', (string)$mountPointPair);
$mountPageInformation = $pageRepository->getMountPointInfo($mountedPage);
if ($mountPageInformation) {
if ($pageId === $mountedPage) {
continue;
}
// Get slugs in the translated page
$mountedPage = $pageRepository->getPage($mountedPage);
$mountRoot = $pageRepository->getPage($mountRoot);
$slugPrefix = $mountedPage['slug'] ?? '';
if ($slugPrefix === '/') {
$slugPrefix = '';
}
$prefixToRemove = $mountRoot['slug'] ?? '';
if ($prefixToRemove === '/') {
$prefixToRemove = '';
}
$prefixesToRemove[] = $prefixToRemove;
$slugPrefixesToAdd[] = $slugPrefix;
}
}
$slugPrefixesToAdd = array_reverse($slugPrefixesToAdd);
$prefixesToRemove = array_reverse($prefixesToRemove);
foreach ($prefixesToRemove as $prefixToRemove) {
// Slug prefixes are taken from the beginning of the array, where as the parts to be removed
// Are taken from the end.
$replacement = array_shift($slugPrefixesToAdd);
if ($prefixToRemove !== '' && str_starts_with($pagePath, $prefixToRemove)) {
$pagePath = substr($pagePath, strlen($prefixToRemove));
}
$pagePath = $replacement . ($pagePath !== '/' ? '/' . ltrim($pagePath, '/') : '');
}
return $pagePath;
}
/**
* Fetch possible enhancers + aspects based on the current page configuration and the site configuration put
* into "routeEnhancers"
*
* @return EnhancerInterface[]
*/
protected function getEnhancersForPage(int $pageId, SiteLanguage $language): array
{
$enhancers = [];
foreach ($this->site->getConfiguration()['routeEnhancers'] ?? [] as $enhancerConfiguration) {
// Check if there is a restriction to page Ids.
if (is_array($enhancerConfiguration['limitToPages'] ?? null) && !in_array($pageId, $enhancerConfiguration['limitToPages'])) {
continue;
}
$enhancerType = $enhancerConfiguration['type'] ?? '';
$enhancer = $this->enhancerFactory->create($enhancerType, $enhancerConfiguration);
if (!empty($enhancerConfiguration['aspects'] ?? null)) {
$aspects = $this->aspectFactory->createAspects(
$enhancerConfiguration['aspects'],
$language,
$this->site
);
$enhancer->setAspects($aspects);
}
$enhancers[] = $enhancer;
}
return $enhancers;
}
protected function generateCacheHash(int $pageId, PageArguments $arguments): string
{
return $this->cacheHashCalculator->calculateCacheHash(
$this->getCacheHashParameters($pageId, $arguments)
);
}
protected function getCacheHashParameters(int $pageId, PageArguments $arguments): array
{
$hashParameters = $arguments->getDynamicArguments();
$hashParameters['id'] = $pageId;
$uri = http_build_query($hashParameters, '', '&', PHP_QUERY_RFC3986);
return $this->cacheHashCalculator->getRelevantParameters($uri);
}
/**
* Builds route arguments. The important part here is to distinguish between
* static and dynamic arguments. Per default all arguments are dynamic until
* aspects can be used to really consider them as static (= 1:1 mapping between
* route value and resulting arguments).
*
* Besides that, internal arguments (_route, _controller, _custom, ..) have
* to be separated since those values are not meant to be used for later
* processing. Not separating those values might result in invalid cHash.
*
* This method is used during resolving and generation of URLs.
*
* @param Route $route
* @param array $results
* @param array $remainingQueryParameters
*/
protected function buildPageArguments(Route $route, array $results, array $remainingQueryParameters = []): PageArguments
{
// only use parameters that actually have been processed
// (thus stripping internals like _route, _controller, ...)
$routeArguments = $this->filterProcessedParameters($route, $results);
// assert amount of "static" mappers is not too "dynamic"
$this->assertMaximumStaticMappableAmount($route, array_keys($routeArguments));
// delegate result handling to enhancer
$enhancer = $route->getEnhancer();
if ($enhancer instanceof ResultingInterface) {
// forward complete(!) results, not just filtered parameters
return $enhancer->buildResult($route, $results, $remainingQueryParameters);
}
$page = $route->getOption('_page');
if ((int)($page['l10n_parent'] ?? 0) > 0) {
$pageId = (int)$page['l10n_parent'];
} elseif ((int)($page['t3ver_oid'] ?? 0) > 0) {
$pageId = (int)$page['t3ver_oid'];
} else {
$pageId = (int)($page['uid'] ?? 0);
}
$type = $this->resolveType($route, $remainingQueryParameters);
// See PageSlugCandidateProvider where this is added.
if ($page['MPvar'] ?? '') {
$routeArguments['MP'] = $page['MPvar'];
}
return new PageArguments($pageId, $type, $routeArguments, [], $remainingQueryParameters);
}
/**
* Retrieves type from processed route and modifies remaining query parameters.
*
* @param array $remainingQueryParameters reference to remaining query parameters
*/
protected function resolveType(Route $route, array &$remainingQueryParameters): string
{
$type = $remainingQueryParameters['type'] ?? 0;
$decoratedParameters = $route->getOption('_decoratedParameters');
if (isset($decoratedParameters['type'])) {
$type = $decoratedParameters['type'];
unset($decoratedParameters['type']);
$remainingQueryParameters = array_replace_recursive(
$remainingQueryParameters,
$decoratedParameters
);
}
return (string)$type;
}
/**
* Asserts that possible amount of items in all static and countable mappers
* (such as StaticRangeMapper) is limited to 10000 in order to avoid
* brute-force scenarios and the risk of cache-flooding.
*
* @throws \OverflowException
*/
protected function assertMaximumStaticMappableAmount(Route $route, array $variableNames = [])
{
// empty when only values of route defaults where used
if (empty($variableNames)) {
return;
}
$mappers = $route->filterAspects(
[StaticMappableAspectInterface::class, \Countable::class],
$variableNames
);
if (empty($mappers)) {
return;
}
$multipliers = array_map('count', $mappers);
$product = array_product($multipliers);
if ($product > 10000) {
throw new \OverflowException(
'Possible range of all mappers is larger than 10000 items',
1537696772
);
}
}
/**
* Determine parameters that have been processed.
*
* @param array $results
*/
protected function filterProcessedParameters(Route $route, $results): array
{
return array_intersect_key(
$results,
array_flip($route->compile()->getPathVariables())
);
}
protected function getSlugCandidateProvider(Context $context): PageSlugCandidateProvider
{
return GeneralUtility::makeInstance(
PageSlugCandidateProvider::class,
$context,
$this->site,
$this->enhancerFactory
);
}
/**
* Request may have been made with default page slug, also we are dealing with a site language variant. To avoid
* duplicate content, we need to revalidate that the eventually matched language route is really the available
* page language variant for the current lange. We do this at this late point to minimize the needed database
* queries instead of checking it for all build page candidates.
*
* This is safe, as we can simply drop the route and having a correct page not found action delivered.
*/
protected function isRouteReallyValidForLanguage(Route $route, SiteLanguage $siteLanguage): bool
{
$page = $route->getOption('_page');
$languageIdField = $GLOBALS['TCA']['pages']['ctrl']['languageField'] ?? '';
if ($languageIdField === '') {
return true;
}
$languageId = (int)($page[$languageIdField] ?? 0);
if ($siteLanguage->getLanguageId() === 0 || $siteLanguage->getLanguageId() === $languageId) {
// default language site request or if page record is same language then siteLanguage, page record
// is valid to use as page resolving candidate and need no further overlay checks.
return true;
}
$pageIdInDefaultLanguage = (int)($languageId > 0 ? $page['l10n_parent'] : $page['uid']);
$pageRepository = GeneralUtility::makeInstance(PageRepository::class, $this->context);
$localizedPage = $pageRepository->getPageOverlay($pageIdInDefaultLanguage, $siteLanguage->getLanguageId());
if (!$localizedPage) {
// no page language overlay found, which means that either language page is not published and no logged
// in backend user OR there is no language overlay for that page at all. Thus using page record to build
// as page resolving candidate is valid.
return true;
}
// we found a valid page overlay, which means that current record is not the valid page for the current
// siteLanguage. To avoid resolving page with multiple slugs for a siteLanguage path, we flag this invalid.
return false;
}
}