[TASK] Add facades for symfony/routing components
[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\ResourceNotFoundException;
23 use Symfony\Component\Routing\Matcher\UrlMatcher;
24 use Symfony\Component\Routing\RequestContext;
25 use TYPO3\CMS\Core\Context\Context;
26 use TYPO3\CMS\Core\Context\LanguageAspect;
27 use TYPO3\CMS\Core\Database\ConnectionPool;
28 use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
29 use TYPO3\CMS\Core\Database\Query\Restriction\FrontendWorkspaceRestriction;
30 use TYPO3\CMS\Core\Http\Uri;
31 use TYPO3\CMS\Core\Site\Entity\Site;
32 use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
33 use TYPO3\CMS\Core\Utility\GeneralUtility;
34 use TYPO3\CMS\Frontend\Page\PageRepository;
35
36 /**
37 * Page Router - responsible for a page based on a request, by looking up the slug of the page path.
38 * Is also used for generating URLs for pages.
39 *
40 * Resolving is done via the "Route Candidate" pattern.
41 *
42 * Example:
43 * - /about-us/team/management/
44 *
45 * will look for all pages that have
46 * - /about-us
47 * - /about-us/
48 * - /about-us/team
49 * - /about-us/team/
50 * - /about-us/team/management
51 * - /about-us/team/management/
52 *
53 * And create route candidates for that.
54 *
55 * Please note: PageRouter does not restrict the HTTP method or is bound to any domain constraints,
56 * as the SiteMatcher has done that already.
57 *
58 * The concept of the PageRouter is to *resolve*, and to build URIs. On top, it is a facade to hide the
59 * dependency to symfony and to not expose its logic.
60 */
61 class PageRouter implements RouterInterface
62 {
63 /**
64 * @var Site
65 */
66 protected $site;
67
68 /**
69 * @var array
70 */
71 protected $configuration;
72
73 /**
74 * A page router is always bound to a specific site.
75 *
76 * @param Site $site
77 * @param array $configuration
78 */
79 public function __construct(Site $site, array $configuration)
80 {
81 $this->site = $site;
82 $this->configuration = $configuration;
83 }
84
85 /**
86 * Finds a RouteResult based on the given request.
87 *
88 * @param ServerRequestInterface $request
89 * @param RouteResultInterface|RouteResult|null $previousResult
90 * @return RouteResult
91 */
92 public function matchRequest(ServerRequestInterface $request, RouteResultInterface $previousResult = null): ?RouteResultInterface
93 {
94 $slugCandidates = $this->getCandidateSlugsFromRoutePath($previousResult->getTail());
95 if (empty($slugCandidates)) {
96 return null;
97 }
98 $language = $previousResult->getLanguage();
99 $pageCandidates = $this->getPagesFromDatabaseForCandidates($slugCandidates, $language->getLanguageId());
100 // Stop if there are no candidates
101 if (empty($pageCandidates)) {
102 return null;
103 }
104
105 $fullCollection = new RouteCollection();
106 foreach ($pageCandidates ?? [] as $page) {
107 $pagePath = $page['slug'];
108 $defaultRouteForPage = new Route(
109 $pagePath . '{tail}',
110 ['tail' => ''],
111 ['tail' => '.*'],
112 ['utf8' => true, '_page' => $page]
113 );
114 $fullCollection->add('page_' . $page['uid'], $defaultRouteForPage);
115 }
116
117 $context = new RequestContext('/', $request->getMethod(), $request->getUri()->getHost());
118 $matcher = new UrlMatcher($fullCollection, $context);
119 try {
120 $result = $matcher->match('/' . ltrim($previousResult->getTail(), '/'));
121 /** @var Route $matchedRoute */
122 $matchedRoute = $fullCollection->get($result['_route']);
123 unset($result['_route']);
124 return $this->buildRouteResult($request, $language, $matchedRoute, $result);
125 } catch (ResourceNotFoundException $e) {
126 // return nothing
127 }
128 return null;
129 }
130
131 /**
132 * API for generating a page where the $route parameter is typically an array (page record) or the page ID
133 *
134 * @param array|string $route
135 * @param array $parameters an array of query parameters which can be built into the URI path, also consider the special handling of "_language"
136 * @param string $fragment additional #my-fragment part
137 * @param string $type see the RouterInterface for possible types
138 * @return UriInterface
139 */
140 public function generateUri($route, array $parameters = [], string $fragment = '', string $type = ''): UriInterface
141 {
142 // Resolve language
143 $language = null;
144 $languageOption = $parameters['_language'] ?? null;
145 if ($languageOption instanceof SiteLanguage) {
146 $language = $languageOption;
147 unset($parameters['_language']);
148 }
149 if ($language === null) {
150 $language = $this->site->getDefaultLanguage();
151 }
152
153 $pageId = 0;
154 if (is_array($route)) {
155 $pageId = (int)$route['uid'];
156 } elseif (is_scalar($route)) {
157 $pageId = (int)$route;
158 }
159
160 $context = clone GeneralUtility::makeInstance(Context::class);
161 $context->setAspect('language', new LanguageAspect($language->getLanguageId()));
162 $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context);
163 $page = $pageRepository->getPage($pageId, true);
164 $pagePath = ltrim($page['slug'] ?? '', '/');
165
166 $prefix = (string)$language->getBase();
167 $prefix = rtrim($prefix, '/') . '/' . $pagePath;
168
169 // Add the query parameters as string
170 $queryString = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
171 $prefix = rtrim($prefix, '?');
172 if (!empty($queryString)) {
173 if (strpos($prefix, '?') === false) {
174 $prefix .= '?';
175 } else {
176 $prefix .= '&';
177 }
178 }
179 $uri = new Uri($prefix . $queryString);
180 if ($fragment) {
181 $uri = $uri->withFragment($fragment);
182 }
183 if ($type === RouterInterface::ABSOLUTE_PATH) {
184 $uri = $uri->withScheme('')->withHost('')->withPort(null);
185 }
186 return $uri;
187 }
188
189 /**
190 * Check for records in the database which matches one of the slug candidates.
191 *
192 * @param array $slugCandidates
193 * @param int $languageId
194 * @return array
195 */
196 protected function getPagesFromDatabaseForCandidates(array $slugCandidates, int $languageId): array
197 {
198 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
199 ->getQueryBuilderForTable('pages');
200 $queryBuilder
201 ->getRestrictions()
202 ->removeAll()
203 ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
204 ->add(GeneralUtility::makeInstance(FrontendWorkspaceRestriction::class));
205
206 $statement = $queryBuilder
207 ->select('uid', 'l10n_parent', 'pid', 'slug')
208 ->from('pages')
209 ->where(
210 $queryBuilder->expr()->eq(
211 'sys_language_uid',
212 $queryBuilder->createNamedParameter($languageId, \PDO::PARAM_INT)
213 ),
214 $queryBuilder->expr()->in(
215 'slug',
216 $queryBuilder->createNamedParameter(
217 $slugCandidates,
218 Connection::PARAM_STR_ARRAY
219 )
220 )
221 )
222 // Exact match will be first, that's important
223 ->orderBy('slug', 'desc')
224 ->execute();
225
226 $pages = [];
227 $siteMatcher = GeneralUtility::makeInstance(SiteMatcher::class);
228 while ($row = $statement->fetch()) {
229 $pageIdInDefaultLanguage = (int)($languageId > 0 ? $row['l10n_parent'] : $row['uid']);
230 if ($siteMatcher->matchByPageId($pageIdInDefaultLanguage)->getRootPageId() === $this->site->getRootPageId()) {
231 $pages[] = $row;
232 }
233 }
234 return $pages;
235 }
236
237 /**
238 * Returns possible URL parts for a string like /home/about-us/offices/
239 * to return.
240 *
241 * /home/about-us/offices/
242 * /home/about-us/offices
243 * /home/about-us/
244 * /home/about-us
245 * /home/
246 * /home
247 *
248 * @param string $routePath
249 * @return array
250 */
251 protected function getCandidateSlugsFromRoutePath(string $routePath): array
252 {
253 $candidatePathParts = [];
254 $pathParts = GeneralUtility::trimExplode('/', $routePath, true);
255 while (!empty($pathParts)) {
256 $prefix = '/' . implode('/', $pathParts);
257 $candidatePathParts[] = $prefix . '/';
258 $candidatePathParts[] = $prefix;
259 array_pop($pathParts);
260 }
261 return $candidatePathParts;
262 }
263
264 /**
265 * @param ServerRequestInterface $request
266 * @param SiteLanguage|null $language
267 * @param Route|null $route
268 * @param array $results
269 * @return RouteResult
270 */
271 protected function buildRouteResult(ServerRequestInterface $request, SiteLanguage $language, Route $route, array $results = []): RouteResult
272 {
273 $data = [];
274 // page record the route has been applied for
275 if ($route->hasOption('_page')) {
276 $data['page'] = $route->getOption('_page');
277 }
278 $tail = $results['tail'] ?? '';
279 return new RouteResult($request->getUri(), $this->site, $language, $tail, $data);
280 }
281 }