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