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