[BUGFIX] Add RootPage to Slug Candidates
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Routing / PageRouter.php
1 <?php
2 declare(strict_types = 1);
3
4 namespace TYPO3\CMS\Core\Routing;
5
6 /*
7 * This file is part of the TYPO3 CMS project.
8 *
9 * It is free software; you can redistribute it and/or modify it under
10 * the terms of the GNU General Public License, either version 2
11 * of the License, or any later version.
12 *
13 * For the full copyright and license information, please read the
14 * LICENSE.txt file that was distributed with this source code.
15 *
16 * The TYPO3 project - inspiring people to share!
17 */
18
19 use Doctrine\DBAL\Connection;
20 use Psr\Http\Message\ServerRequestInterface;
21 use Psr\Http\Message\UriInterface;
22 use Symfony\Component\Routing\Exception\MissingMandatoryParametersException;
23 use Symfony\Component\Routing\Exception\ResourceNotFoundException;
24 use Symfony\Component\Routing\Generator\UrlGenerator;
25 use Symfony\Component\Routing\RequestContext;
26 use TYPO3\CMS\Core\Context\Context;
27 use TYPO3\CMS\Core\Context\LanguageAspect;
28 use TYPO3\CMS\Core\Database\ConnectionPool;
29 use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
30 use TYPO3\CMS\Core\Database\Query\Restriction\FrontendWorkspaceRestriction;
31 use TYPO3\CMS\Core\Exception\SiteNotFoundException;
32 use TYPO3\CMS\Core\Http\Uri;
33 use TYPO3\CMS\Core\Routing\Aspect\AspectFactory;
34 use TYPO3\CMS\Core\Routing\Aspect\MappableProcessor;
35 use TYPO3\CMS\Core\Routing\Aspect\StaticMappableAspectInterface;
36 use TYPO3\CMS\Core\Routing\Enhancer\DecoratingEnhancerInterface;
37 use TYPO3\CMS\Core\Routing\Enhancer\EnhancerFactory;
38 use TYPO3\CMS\Core\Routing\Enhancer\EnhancerInterface;
39 use TYPO3\CMS\Core\Routing\Enhancer\ResultingInterface;
40 use TYPO3\CMS\Core\Routing\Enhancer\RoutingEnhancerInterface;
41 use TYPO3\CMS\Core\Site\Entity\Site;
42 use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
43 use TYPO3\CMS\Core\Utility\GeneralUtility;
44 use TYPO3\CMS\Frontend\Page\CacheHashCalculator;
45 use TYPO3\CMS\Frontend\Page\PageRepository;
46
47 /**
48 * Page Router - responsible for a page based on a request, by looking up the slug of the page path.
49 * Is also used for generating URLs for pages.
50 *
51 * Resolving is done via the "Route Candidate" pattern.
52 *
53 * Example:
54 * - /about-us/team/management/
55 *
56 * will look for all pages that have
57 * - /about-us
58 * - /about-us/
59 * - /about-us/team
60 * - /about-us/team/
61 * - /about-us/team/management
62 * - /about-us/team/management/
63 *
64 * And create route candidates for that.
65 *
66 * Please note: PageRouter does not restrict the HTTP method or is bound to any domain constraints,
67 * as the SiteMatcher has done that already.
68 *
69 * The concept of the PageRouter is to *resolve*, and to *generate* URIs. On top, it is a facade to hide the
70 * dependency to symfony and to not expose its logic.
71 */
72 class PageRouter implements RouterInterface
73 {
74 /**
75 * @var Site
76 */
77 protected $site;
78
79 /**
80 * @var EnhancerFactory
81 */
82 protected $enhancerFactory;
83
84 /**
85 * @var AspectFactory
86 */
87 protected $aspectFactory;
88
89 /**
90 * @var CacheHashCalculator
91 */
92 protected $cacheHashCalculator;
93
94 /**
95 * A page router is always bound to a specific site.
96 * @param Site $site
97 */
98 public function __construct(Site $site)
99 {
100 $this->site = $site;
101 $this->enhancerFactory = GeneralUtility::makeInstance(EnhancerFactory::class);
102 $this->aspectFactory = GeneralUtility::makeInstance(AspectFactory::class);
103 $this->cacheHashCalculator = GeneralUtility::makeInstance(CacheHashCalculator::class);
104 }
105
106 /**
107 * Finds a RouteResult based on the given request.
108 *
109 * @param ServerRequestInterface $request
110 * @param RouteResultInterface|SiteRouteResult|null $previousResult
111 * @return SiteRouteResult
112 * @throws RouteNotFoundException
113 */
114 public function matchRequest(ServerRequestInterface $request, RouteResultInterface $previousResult = null): RouteResultInterface
115 {
116 $urlPath = $previousResult->getTail();
117 $slugCandidates = $this->getCandidateSlugsFromRoutePath($urlPath ?: '/');
118 $language = $previousResult->getLanguage();
119 $pageCandidates = $this->getPagesFromDatabaseForCandidates($slugCandidates, $language->getLanguageId());
120 // Stop if there are no candidates
121 if (empty($pageCandidates)) {
122 throw new RouteNotFoundException('No page candidates found for path "' . $urlPath . '"', 1538389999);
123 }
124
125 $fullCollection = new RouteCollection();
126 foreach ($pageCandidates ?? [] as $page) {
127 $pageIdForDefaultLanguage = (int)($page['l10n_parent'] ?: $page['uid']);
128 $pagePath = $page['slug'];
129 $pageCollection = new RouteCollection();
130 $defaultRouteForPage = new Route(
131 $pagePath,
132 [],
133 [],
134 ['utf8' => true, '_page' => $page]
135 );
136 $pageCollection->add('default', $defaultRouteForPage);
137 $enhancers = $this->getEnhancersForPage($pageIdForDefaultLanguage, $language);
138 foreach ($enhancers as $enhancer) {
139 if ($enhancer instanceof DecoratingEnhancerInterface) {
140 $enhancer->decorateForMatching($pageCollection, $urlPath);
141 }
142 }
143 foreach ($enhancers as $enhancer) {
144 if ($enhancer instanceof RoutingEnhancerInterface) {
145 $enhancer->enhanceForMatching($pageCollection);
146 }
147 }
148
149 $pageCollection->addNamePrefix('page_' . $page['uid'] . '_');
150 $fullCollection->addCollection($pageCollection);
151 }
152
153 $matcher = new PageUriMatcher($fullCollection);
154 try {
155 $result = $matcher->match('/' . trim($urlPath, '/'));
156 /** @var Route $matchedRoute */
157 $matchedRoute = $fullCollection->get($result['_route']);
158 return $this->buildPageArguments($matchedRoute, $result, $request->getQueryParams());
159 } catch (ResourceNotFoundException $e) {
160 // Do nothing
161 }
162 throw new RouteNotFoundException('No route found for path "' . $urlPath . '"', 1538389998);
163 }
164
165 /**
166 * API for generating a page where the $route parameter is typically an array (page record) or the page ID
167 *
168 * @param array|string $route
169 * @param array $parameters an array of query parameters which can be built into the URI path, also consider the special handling of "_language"
170 * @param string $fragment additional #my-fragment part
171 * @param string $type see the RouterInterface for possible types
172 * @return UriInterface
173 * @throws InvalidRouteArgumentsException
174 */
175 public function generateUri($route, array $parameters = [], string $fragment = '', string $type = ''): UriInterface
176 {
177 // Resolve language
178 $language = null;
179 $languageOption = $parameters['_language'] ?? null;
180 unset($parameters['_language']);
181 if ($languageOption instanceof SiteLanguage) {
182 $language = $languageOption;
183 } elseif ($languageOption !== null) {
184 $language = $this->site->getLanguageById((int)$languageOption);
185 }
186 if ($language === null) {
187 $language = $this->site->getDefaultLanguage();
188 }
189
190 $pageId = 0;
191 if (is_array($route)) {
192 $pageId = (int)$route['uid'];
193 } elseif (is_scalar($route)) {
194 $pageId = (int)$route;
195 }
196
197 $context = clone GeneralUtility::makeInstance(Context::class);
198 $context->setAspect('language', new LanguageAspect($language->getLanguageId()));
199 $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context);
200 $page = $pageRepository->getPage($pageId, true);
201 $pagePath = ltrim($page['slug'] ?? '', '/');
202 $originalParameters = $parameters;
203 $collection = new RouteCollection();
204 $defaultRouteForPage = new Route(
205 '/' . $pagePath,
206 [],
207 [],
208 ['utf8' => true, '_page' => $page]
209 );
210 $collection->add('default', $defaultRouteForPage);
211
212 // cHash is never considered because cHash is built by this very method.
213 unset($originalParameters['cHash']);
214 $enhancers = $this->getEnhancersForPage($pageId, $language);
215 foreach ($enhancers as $enhancer) {
216 if ($enhancer instanceof RoutingEnhancerInterface) {
217 $enhancer->enhanceForGeneration($collection, $originalParameters);
218 }
219 }
220 foreach ($enhancers as $enhancer) {
221 if ($enhancer instanceof DecoratingEnhancerInterface) {
222 $enhancer->decorateForGeneration($collection, $originalParameters);
223 }
224 }
225
226 $scheme = $language->getBase()->getScheme();
227 $mappableProcessor = new MappableProcessor();
228 $context = new RequestContext(
229 // page segment (slug & enhanced part) is supposed to start with '/'
230 rtrim($language->getBase()->getPath(), '/'),
231 'GET',
232 $language->getBase()->getHost(),
233 $scheme ?: 'http',
234 $scheme === 'http' ? $language->getBase()->getPort() ?? 80 : 80,
235 $scheme === 'https' ? $language->getBase()->getPort() ?? 443 : 443
236 );
237 $generator = new UrlGenerator($collection, $context);
238 $allRoutes = $collection->all();
239 $allRoutes = array_reverse($allRoutes, true);
240 $matchedRoute = null;
241 $pageRouteResult = null;
242 $uri = null;
243 // map our reference type to symfony's custom paths
244 $referenceType = $type === static::ABSOLUTE_PATH ? UrlGenerator::ABSOLUTE_PATH : UrlGenerator::ABSOLUTE_URL;
245 /**
246 * @var string $routeName
247 * @var Route $route
248 */
249 foreach ($allRoutes as $routeName => $route) {
250 try {
251 $parameters = $originalParameters;
252 if ($route->hasOption('deflatedParameters')) {
253 $parameters = $route->getOption('deflatedParameters');
254 }
255 $mappableProcessor->generate($route, $parameters);
256 // ABSOLUTE_URL is used as default fallback
257 $urlAsString = $generator->generate($routeName, $parameters, $referenceType);
258 $uri = new Uri($urlAsString);
259 /** @var Route $matchedRoute */
260 $matchedRoute = $collection->get($routeName);
261 parse_str($uri->getQuery() ?? '', $remainingQueryParameters);
262 $pageRouteResult = $this->buildPageArguments($route, $parameters, $remainingQueryParameters);
263 break;
264 } catch (MissingMandatoryParametersException $e) {
265 // no match
266 }
267 }
268
269 if (!$uri instanceof UriInterface) {
270 throw new InvalidRouteArgumentsException('Uri could not be built for page "' . $pageId . '"', 1538390230);
271 }
272
273 if ($pageRouteResult && $pageRouteResult->areDirty()) {
274 // for generating URLs this should(!) never happen
275 // if it does happen, generator logic has flaws
276 throw new InvalidRouteArgumentsException('Route arguments are dirty', 1537613247);
277 }
278
279 if ($matchedRoute && $pageRouteResult && !empty($pageRouteResult->getDynamicArguments())) {
280 $cacheHash = $this->generateCacheHash($pageId, $pageRouteResult);
281
282 if (!empty($cacheHash)) {
283 $queryArguments = $pageRouteResult->getQueryArguments();
284 $queryArguments['cHash'] = $cacheHash;
285 $uri = $uri->withQuery(http_build_query($queryArguments, '', '&', PHP_QUERY_RFC3986));
286 }
287 }
288 if ($fragment) {
289 $uri = $uri->withFragment($fragment);
290 }
291 return $uri;
292 }
293
294 /**
295 * Check for records in the database which matches one of the slug candidates.
296 *
297 * @param array $slugCandidates
298 * @param int $languageId
299 * @return array
300 */
301 protected function getPagesFromDatabaseForCandidates(array $slugCandidates, int $languageId): array
302 {
303 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
304 ->getQueryBuilderForTable('pages');
305 $queryBuilder
306 ->getRestrictions()
307 ->removeAll()
308 ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
309 ->add(GeneralUtility::makeInstance(FrontendWorkspaceRestriction::class));
310
311 $statement = $queryBuilder
312 ->select('uid', 'l10n_parent', 'pid', 'slug')
313 ->from('pages')
314 ->where(
315 $queryBuilder->expr()->eq(
316 'sys_language_uid',
317 $queryBuilder->createNamedParameter($languageId, \PDO::PARAM_INT)
318 ),
319 $queryBuilder->expr()->in(
320 'slug',
321 $queryBuilder->createNamedParameter(
322 $slugCandidates,
323 Connection::PARAM_STR_ARRAY
324 )
325 )
326 )
327 // Exact match will be first, that's important
328 ->orderBy('slug', 'desc')
329 ->execute();
330
331 $pages = [];
332 $siteMatcher = GeneralUtility::makeInstance(SiteMatcher::class);
333 while ($row = $statement->fetch()) {
334 $pageIdInDefaultLanguage = (int)($languageId > 0 ? $row['l10n_parent'] : $row['uid']);
335 try {
336 if ($siteMatcher->matchByPageId($pageIdInDefaultLanguage)->getRootPageId() === $this->site->getRootPageId()) {
337 $pages[] = $row;
338 }
339 } catch (SiteNotFoundException $e) {
340 }
341 }
342 return $pages;
343 }
344
345 /**
346 * Fetch possible enhancers + aspects based on the current page configuration and the site configuration put
347 * into "routeEnhancers"
348 *
349 * @param int $pageId
350 * @param SiteLanguage $language
351 * @return EnhancerInterface[]
352 */
353 protected function getEnhancersForPage(int $pageId, SiteLanguage $language): array
354 {
355 $enhancers = [];
356 foreach ($this->site->getConfiguration()['routeEnhancers'] ?? [] as $enhancerConfiguration) {
357 // Check if there is a restriction to page Ids.
358 if (is_array($enhancerConfiguration['limitToPages'] ?? null) && !in_array($pageId, $enhancerConfiguration['limitToPages'])) {
359 continue;
360 }
361 $enhancerType = $enhancerConfiguration['type'] ?? '';
362 $enhancer = $this->enhancerFactory->create($enhancerType, $enhancerConfiguration);
363 if (!empty($enhancerConfiguration['aspects'] ?? null)) {
364 $aspects = $this->aspectFactory->createAspects(
365 $enhancerConfiguration['aspects'],
366 $language
367 );
368 $enhancer->setAspects($aspects);
369 }
370 $enhancers[] = $enhancer;
371 }
372 return $enhancers;
373 }
374
375 /**
376 * Resolves decorating enhancers without having aspects assigned. These
377 * instances are used to pre-process URL path and MUST NOT be used for
378 * actually resolving or generating URL parameters.
379 *
380 * @return DecoratingEnhancerInterface[]
381 */
382 protected function getDecoratingEnhancers(): array
383 {
384 $enhancers = [];
385 foreach ($this->site->getConfiguration()['routeEnhancers'] ?? [] as $enhancerConfiguration) {
386 $enhancerType = $enhancerConfiguration['type'] ?? '';
387 $enhancer = $this->enhancerFactory->create($enhancerType, $enhancerConfiguration);
388 if ($enhancer instanceof DecoratingEnhancerInterface) {
389 $enhancers[] = $enhancer;
390 }
391 }
392 return $enhancers;
393 }
394
395 /**
396 * Gets all patterns that can be used to redecorate (undecorate) a
397 * potential previously decorated route path.
398 *
399 * @return string regular expression pattern capable of redecorating
400 */
401 protected function getRoutePathRedecorationPattern(): string
402 {
403 $decoratingEnhancers = $this->getDecoratingEnhancers();
404 if (empty($decoratingEnhancers)) {
405 return '';
406 }
407 $redecorationPatterns = array_map(
408 function (DecoratingEnhancerInterface $decorationEnhancers) {
409 $pattern = $decorationEnhancers->getRoutePathRedecorationPattern();
410 return '(?:' . $pattern . ')';
411 },
412 $decoratingEnhancers
413 );
414 return '(?P<decoration>' . implode('|', $redecorationPatterns) . ')';
415 }
416
417 /**
418 * Returns possible URL parts for a string like /home/about-us/offices/ or /home/about-us/offices.json
419 * to return.
420 *
421 * /home/about-us/offices/
422 * /home/about-us/offices.json
423 * /home/about-us/offices
424 * /home/about-us/
425 * /home/about-us
426 * /home/
427 * /home
428 * /
429 *
430 * @param string $routePath
431 * @return array
432 */
433 protected function getCandidateSlugsFromRoutePath(string $routePath): array
434 {
435 $redecorationPattern = $this->getRoutePathRedecorationPattern();
436 if (!empty($redecorationPattern) && preg_match('#' . $redecorationPattern . '#', $routePath, $matches)) {
437 $decoration = $matches['decoration'];
438 $decorationPattern = preg_quote($decoration, '#');
439 $routePath = preg_replace('#' . $decorationPattern . '$#', '', $routePath);
440 }
441
442 $candidatePathParts = [];
443 $pathParts = GeneralUtility::trimExplode('/', $routePath, true);
444 if (empty($pathParts)) {
445 return ['/'];
446 }
447
448 while (!empty($pathParts)) {
449 $prefix = '/' . implode('/', $pathParts);
450 $candidatePathParts[] = $prefix . '/';
451 $candidatePathParts[] = $prefix;
452 array_pop($pathParts);
453 }
454 $candidatePathParts[] = '/';
455 return $candidatePathParts;
456 }
457
458 /**
459 * @param int $pageId
460 * @param PageArguments $arguments
461 * @return string
462 */
463 protected function generateCacheHash(int $pageId, PageArguments $arguments): string
464 {
465 return $this->cacheHashCalculator->calculateCacheHash(
466 $this->getCacheHashParameters($pageId, $arguments)
467 );
468 }
469
470 /**
471 * @param int $pageId
472 * @param PageArguments $arguments
473 * @return array
474 */
475 protected function getCacheHashParameters(int $pageId, PageArguments $arguments): array
476 {
477 $hashParameters = $arguments->getDynamicArguments();
478 $hashParameters['id'] = $pageId;
479 $uri = http_build_query($hashParameters, '', '&', PHP_QUERY_RFC3986);
480 return $this->cacheHashCalculator->getRelevantParameters($uri);
481 }
482
483 /**
484 * Builds route arguments. The important part here is to distinguish between
485 * static and dynamic arguments. Per default all arguments are dynamic until
486 * aspects can be used to really consider them as static (= 1:1 mapping between
487 * route value and resulting arguments).
488 *
489 * Besides that, internal arguments (_route, _controller, _custom, ..) have
490 * to be separated since those values are not meant to be used for later
491 * processing. Not separating those values might result in invalid cHash.
492 *
493 * This method is used during resolving and generation of URLs.
494 *
495 * @param Route $route
496 * @param array $results
497 * @param array $remainingQueryParameters
498 * @return PageArguments
499 */
500 protected function buildPageArguments(Route $route, array $results, array $remainingQueryParameters = []): PageArguments
501 {
502 // only use parameters that actually have been processed
503 // (thus stripping internals like _route, _controller, ...)
504 $routeArguments = $this->filterProcessedParameters($route, $results);
505 // assert amount of "static" mappers is not too "dynamic"
506 $this->assertMaximumStaticMappableAmount($route, array_keys($routeArguments));
507 // delegate result handling to enhancer
508 $enhancer = $route->getEnhancer();
509 if ($enhancer instanceof ResultingInterface) {
510 // forward complete(!) results, not just filtered parameters
511 return $enhancer->buildResult($route, $results, $remainingQueryParameters);
512 }
513 $page = $route->getOption('_page');
514 $pageId = (int)($page['l10n_parent'] > 0 ? $page['l10n_parent'] : $page['uid']);
515 $type = $this->resolveType($route, $remainingQueryParameters);
516 return new PageArguments($pageId, $type, $routeArguments, [], $remainingQueryParameters);
517 }
518
519 /**
520 * Retrieves type from processed route and modifies remaining query parameters.
521 *
522 * @param Route $route
523 * @param array $remainingQueryParameters reference to remaining query parameters
524 * @return string
525 */
526 protected function resolveType(Route $route, array &$remainingQueryParameters): string
527 {
528 $type = $remainingQueryParameters['type'] ?? 0;
529 $decoratedParameters = $route->getOption('_decoratedParameters');
530 if (isset($decoratedParameters['type'])) {
531 $type = $decoratedParameters['type'];
532 unset($decoratedParameters['type']);
533 $remainingQueryParameters = array_replace_recursive(
534 $remainingQueryParameters,
535 $decoratedParameters
536 );
537 }
538 return (string)$type;
539 }
540
541 /**
542 * Asserts that possible amount of items in all static and countable mappers
543 * (such as StaticRangeMapper) is limited to 10000 in order to avoid
544 * brute-force scenarios and the risk of cache-flooding.
545 *
546 * @param Route $route
547 * @param array $variableNames
548 * @throws \OverflowException
549 */
550 protected function assertMaximumStaticMappableAmount(Route $route, array $variableNames = [])
551 {
552 $mappers = $route->filterAspects(
553 [StaticMappableAspectInterface::class, \Countable::class],
554 $variableNames
555 );
556 if (empty($mappers)) {
557 return;
558 }
559
560 $multipliers = array_map('count', $mappers);
561 $product = array_product($multipliers);
562 if ($product > 10000) {
563 throw new \OverflowException(
564 'Possible range of all mappers is larger than 10000 items',
565 1537696772
566 );
567 }
568 }
569
570 /**
571 * Determine parameters that have been processed.
572 *
573 * @param Route $route
574 * @param array $results
575 * @return array
576 */
577 protected function filterProcessedParameters(Route $route, $results): array
578 {
579 return array_intersect_key(
580 $results,
581 array_flip($route->compile()->getPathVariables())
582 );
583 }
584 }