[BUGFIX] Allow to resolve sites without trailing slash as base
[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 Symfony\Component\Routing\Exception\ResourceNotFoundException;
22 use Symfony\Component\Routing\Matcher\UrlMatcher;
23 use Symfony\Component\Routing\RequestContext;
24 use Symfony\Component\Routing\Route;
25 use Symfony\Component\Routing\RouteCollection;
26 use TYPO3\CMS\Core\Database\ConnectionPool;
27 use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
28 use TYPO3\CMS\Core\Site\Entity\SiteInterface;
29 use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
30 use TYPO3\CMS\Core\Utility\GeneralUtility;
31
32 /**
33 * Page Router looking up the slug of the page path.
34 *
35 * This is done via the "Route Candidate" pattern.
36 *
37 * Example:
38 * - /about-us/team/management/
39 *
40 * will look for all pages that have
41 * - /about-us
42 * - /about-us/
43 * - /about-us/team
44 * - /about-us/team/
45 * - /about-us/team/management
46 * - /about-us/team/management/
47 *
48 * And create route candidates for that.
49 *
50 * Please note: PageRouter does not restrict the HTTP method or is bound to any domain constraints,
51 * as the SiteMatcher has done that already.
52 *
53 * The concept of the PageRouter is to *resolve*, and not build URIs. On top, it is a facade to hide the
54 * dependency to symfony and to not expose its logic.
55
56 * @internal This API is not public yet and might change in the future, until TYPO3 v9 or TYPO3 v10.
57 */
58 class PageRouter
59 {
60 /**
61 * @param ServerRequestInterface $request
62 * @param string $routePathTail
63 * @param SiteInterface $site
64 * @param SiteLanguage $language
65 * @return RouteResult|null
66 */
67 public function matchRoute(ServerRequestInterface $request, string $routePathTail, SiteInterface $site, SiteLanguage $language): ?RouteResult
68 {
69 $slugCandidates = $this->getCandidateSlugsFromRoutePath($routePathTail);
70 if (empty($slugCandidates)) {
71 return null;
72 }
73 $pageCandidates = $this->getPagesFromDatabaseForCandidates($slugCandidates, $site, $language->getLanguageId());
74 // Stop if there are no candidates
75 if (empty($pageCandidates)) {
76 return null;
77 }
78
79 $collection = new RouteCollection();
80 foreach ($pageCandidates ?? [] as $page) {
81 $path = $page['slug'];
82 $route = new Route(
83 $path . '{tail}',
84 ['page' => $page, 'tail' => ''],
85 ['tail' => '.*'],
86 ['utf8' => true]
87 );
88 $collection->add('page_' . $page['uid'], $route);
89 }
90
91 $context = new RequestContext('/', $request->getMethod(), $request->getUri()->getHost());
92 $matcher = new UrlMatcher($collection, $context);
93 try {
94 $result = $matcher->match('/' . ltrim($routePathTail, '/'));
95 unset($result['_route']);
96 return new RouteResult($request->getUri(), $site, $language, $result['tail'], $result);
97 } catch (ResourceNotFoundException $e) {
98 // do nothing
99 }
100 return new RouteResult($request->getUri(), $site, $language);
101 }
102
103 /**
104 * Check for records in the database which matches one of the slug candidates.
105 *
106 * @param array $slugCandidates
107 * @param SiteInterface $site
108 * @param int $languageId
109 * @return array
110 */
111 protected function getPagesFromDatabaseForCandidates(array $slugCandidates, SiteInterface $site, int $languageId): array
112 {
113 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
114 ->getQueryBuilderForTable('pages');
115 $queryBuilder
116 ->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
117
118 $statement = $queryBuilder
119 ->select('uid', 'l10n_parent', 'pid', 'slug')
120 ->from('pages')
121 ->where(
122 $queryBuilder->expr()->eq(
123 'sys_language_uid',
124 $queryBuilder->createNamedParameter($languageId, \PDO::PARAM_INT)
125 ),
126 $queryBuilder->expr()->in(
127 'slug',
128 $queryBuilder->createNamedParameter(
129 $slugCandidates,
130 Connection::PARAM_STR_ARRAY
131 )
132 )
133 )
134 // Exact match will be first, that's important
135 ->orderBy('slug', 'desc')
136 ->execute();
137
138 $pages = [];
139 $siteMatcher = GeneralUtility::makeInstance(SiteMatcher::class);
140 while ($row = $statement->fetch()) {
141 $pageIdInDefaultLanguage = (int)($languageId > 0 ? $row['l10n_parent'] : $row['uid']);
142 if ($siteMatcher->matchByPageId($pageIdInDefaultLanguage)->getRootPageId() === $site->getRootPageId()) {
143 $pages[] = $row;
144 }
145 }
146 return $pages;
147 }
148
149 /**
150 * Returns possible URL parts for a string like /home/about-us/offices/
151 * to return
152 * /home/about-us/offices/
153 * /home/about-us/offices
154 * /home/about-us/
155 * /home/about-us
156 * /home/
157 * /home
158 *
159 * @param string $routePath
160 * @return array
161 */
162 protected function getCandidateSlugsFromRoutePath(string $routePath): array
163 {
164 $candidatePathParts = [];
165 $pathParts = GeneralUtility::trimExplode('/', $routePath, true);
166 while (!empty($pathParts)) {
167 $prefix = '/' . implode('/', $pathParts);
168 $candidatePathParts[] = $prefix . '/';
169 $candidatePathParts[] = $prefix;
170 array_pop($pathParts);
171 }
172 return $candidatePathParts;
173 }
174 }