711 lines
32 KiB
PHP
711 lines
32 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 Psr\EventDispatcher\EventDispatcherInterface;
|
|
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\Crypto\HashService;
|
|
use TYPO3\CMS\Core\Domain\Page;
|
|
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
|
|
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
|
|
use TYPO3\CMS\Core\ExpressionLanguage\Resolver;
|
|
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\Routing\Event\AfterPageUriGeneratedEvent;
|
|
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
|
|
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
|
|
use TYPO3\CMS\Core\Site\Entity\Site;
|
|
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
use TYPO3\CMS\Core\Utility\MathUtility;
|
|
use TYPO3\CMS\Frontend\Page\CacheHashCalculator;
|
|
use TYPO3\CMS\Frontend\Page\CacheHashConfiguration;
|
|
|
|
/**
|
|
* 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;
|
|
protected EventDispatcherInterface $eventDispatcher;
|
|
|
|
/**
|
|
* 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,
|
|
GeneralUtility::makeInstance(CacheHashConfiguration::class),
|
|
GeneralUtility::makeInstance(HashService::class)
|
|
);
|
|
$this->requestContextFactory = GeneralUtility::makeInstance(RequestContextFactory::class);
|
|
$this->eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class);
|
|
}
|
|
|
|
/**
|
|
* Finds a RouteResult based on the given request.
|
|
*/
|
|
public function matchRequest(ServerRequestInterface $request, ?RouteResultInterface $previousResult = null): RouteResultInterface|PageArguments
|
|
{
|
|
if (!$previousResult instanceof SiteRouteResult) {
|
|
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 = ($request->getQueryParams()['id'] ?? null);
|
|
$type = '0';
|
|
if (isset($request->getQueryParams()['type']) && is_scalar($request->getQueryParams()['type'])) {
|
|
$type = (string)$request->getQueryParams()['type'];
|
|
}
|
|
if ($requestId !== null) {
|
|
if (MathUtility::canBeInterpretedAsInteger($requestId)
|
|
&& (int)$requestId > 0
|
|
&& !empty($pageId = $candidateProvider->getRealPageIdForPageIdAsPossibleCandidate((int)$requestId))
|
|
) {
|
|
return new PageArguments((int)$pageId, $type, [], [], $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, $page);
|
|
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
|
|
{
|
|
// sanitize superfluous page-id from additional parameters
|
|
// (even if `$parameters['id']` is different to `$pageId`, it will be removed)
|
|
unset($parameters['id']);
|
|
// 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(true);
|
|
} elseif (is_array($route)
|
|
// Check 3rd party input $route for basic requirements
|
|
&& isset($route['uid'], $route['language_tag'], $route['l10n_parent'], $route['slug'])
|
|
&& (int)$route['language_tag'] === $language->getLanguageId()
|
|
&& ((int)$route['l10n_parent'] === 0 || isset($route['_LOCALIZED_UID']))
|
|
) {
|
|
$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, $page);
|
|
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 $routeCandidate
|
|
*/
|
|
foreach ($allRoutes as $routeName => $routeCandidate) {
|
|
try {
|
|
$parameters = $originalParameters;
|
|
if ($routeCandidate->hasOption('deflatedParameters')) {
|
|
$parameters = $routeCandidate->getOption('deflatedParameters');
|
|
}
|
|
// skip the route, in case any aspect of it could not be mapped to a value
|
|
if ($mappableProcessor->generate($routeCandidate, $parameters) === false) {
|
|
continue;
|
|
}
|
|
// 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 = $routeCandidate->getEnhancer();
|
|
if ($enhancer instanceof InflatableEnhancerInterface) {
|
|
$remainingQueryParameters = $enhancer->inflateParameters($remainingQueryParameters);
|
|
}
|
|
$pageRouteResult = $this->buildPageArguments($routeCandidate, 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);
|
|
}
|
|
|
|
$event = new AfterPageUriGeneratedEvent($uri, $route, $originalParameters, $fragment, $type, $language, $this->site);
|
|
$this->eventDispatcher->dispatch($event);
|
|
return $event->getUri();
|
|
}
|
|
|
|
/**
|
|
* 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 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)
|
|
*/
|
|
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 $page = []): array
|
|
{
|
|
$enhancers = [];
|
|
$resolver = null;
|
|
foreach ($this->site->getConfiguration()['routeEnhancers'] ?? [] as $enhancerConfiguration) {
|
|
if (is_array($enhancerConfiguration['limitToPages'] ?? null)
|
|
&& !$this->matchesPageLimitation($enhancerConfiguration['limitToPages'], $pageId, $page, $language, $resolver)
|
|
) {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Checks whether the current page matches any of the limitToPages conditions.
|
|
* Each entry in the array is OR-combined:
|
|
* - Integer values are matched against the page ID (existing behavior)
|
|
* - String values are evaluated as Symfony ExpressionLanguage expressions
|
|
* with access to the `page`, `site` and `siteLanguage` variables
|
|
*/
|
|
protected function matchesPageLimitation(array $limitToPages, int $pageId, array $page, SiteLanguage $language, ?Resolver &$resolver): bool
|
|
{
|
|
foreach ($limitToPages as $limitation) {
|
|
if (is_int($limitation)) {
|
|
if ($limitation === $pageId) {
|
|
return true;
|
|
}
|
|
continue;
|
|
}
|
|
if (is_string($limitation) && $limitation !== '') {
|
|
if ($page === []) {
|
|
continue;
|
|
}
|
|
$resolver ??= GeneralUtility::makeInstance(
|
|
Resolver::class,
|
|
'routing',
|
|
[
|
|
'page' => $page,
|
|
'site' => $this->site,
|
|
'siteLanguage' => $language,
|
|
]
|
|
);
|
|
try {
|
|
if ($resolver->evaluate($limitation)) {
|
|
return true;
|
|
}
|
|
} catch (\Exception) {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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
|
|
);
|
|
}
|
|
if (is_scalar($type)) {
|
|
return (string)$type;
|
|
}
|
|
return '0';
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @todo with having `static` route variables, this restriction should be configurable & optional
|
|
*/
|
|
protected function assertMaximumStaticMappableAmount(Route $route, array $variableNames = [])
|
|
{
|
|
// empty when only values of route defaults where used
|
|
if ($variableNames === []) {
|
|
return;
|
|
}
|
|
$mappers = $route->filterAspects(
|
|
[StaticMappableAspectInterface::class, \Countable::class],
|
|
$variableNames
|
|
);
|
|
if ($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.
|
|
*/
|
|
protected function filterProcessedParameters(Route $route, array $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');
|
|
$schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get('pages');
|
|
if (!$schema->isLanguageAware()) {
|
|
return true;
|
|
}
|
|
$languageIdField = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
|
|
$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;
|
|
}
|
|
}
|