[TASK] Clean up Routing API
[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 Symfony\Component\Routing\Route;
26 use Symfony\Component\Routing\RouteCollection;
27 use TYPO3\CMS\Backend\Utility\BackendUtility;
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\Site\Entity\Site;
33 use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
34 use TYPO3\CMS\Core\Utility\GeneralUtility;
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 $routePathTail = $previousResult ? $previousResult->getTail() : '';
95 $language = $previousResult ? $previousResult->getLanguage() : null;
96 $slugCandidates = $this->getCandidateSlugsFromRoutePath($routePathTail);
97 if (empty($slugCandidates)) {
98 return null;
99 }
100 $pageCandidates = $this->getPagesFromDatabaseForCandidates($slugCandidates, $language->getLanguageId());
101 // Stop if there are no candidates
102 if (empty($pageCandidates)) {
103 return null;
104 }
105
106 $collection = new RouteCollection();
107 foreach ($pageCandidates ?? [] as $page) {
108 $path = $page['slug'];
109 $route = new Route(
110 $path . '{tail}',
111 ['page' => $page, 'tail' => ''],
112 ['tail' => '.*'],
113 ['utf8' => true]
114 );
115 $collection->add('page_' . $page['uid'], $route);
116 }
117
118 $context = new RequestContext('/', $request->getMethod(), $request->getUri()->getHost());
119 $matcher = new UrlMatcher($collection, $context);
120 try {
121 $result = $matcher->match('/' . ltrim($routePathTail, '/'));
122 unset($result['_route']);
123 return new RouteResult($request->getUri(), $this->site, $language, $result['tail'], $result);
124 } catch (ResourceNotFoundException $e) {
125 // do nothing
126 }
127 return new RouteResult($request->getUri(), $this->site, $language);
128 }
129
130 /**
131 * API for generating a page where the $route parameter is typically an array (page record) or the page ID
132 *
133 * @param array|string $route
134 * @param array $parameters an array of query parameters which can be built into the URI path, also consider the special handling of "_language"
135 * @param string $fragment additional #my-fragment part
136 * @param string $type see the RouterInterface for possible types
137 * @return UriInterface
138 */
139 public function generateUri($route, array $parameters = [], string $fragment = '', string $type = ''): UriInterface
140 {
141 // Resolve site
142 $siteLanguage = null;
143 $languageOption = $parameters['_language'] ?? null;
144 if ($languageOption instanceof SiteLanguage) {
145 $siteLanguage = $languageOption;
146 unset($parameters['_language']);
147 }
148 if ($siteLanguage === null) {
149 $siteLanguage = $this->site->getDefaultLanguage();
150 }
151
152 $pageId = 0;
153 if (is_array($route)) {
154 $pageId = (int)$route['uid'];
155 } elseif (is_scalar($route)) {
156 $pageId = (int)$route;
157 }
158 $pageRecord = BackendUtility::getRecord('pages', $pageId);
159 if ($siteLanguage->getLanguageId() > 0) {
160 $pageLocalizations = BackendUtility::getRecordLocalization('pages', $pageId, $siteLanguage->getLanguageId());
161 $pageRecord = $pageLocalizations[0] ?? $pageRecord;
162 }
163 $prefix = (string)$siteLanguage->getBase();
164 $prefix = rtrim($prefix, '/') . '/' . ltrim($pageRecord['slug'] ?? '', '/');
165
166 // Add the query parameters as string
167 $queryString = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
168 $prefix = rtrim($prefix, '?');
169 if (!empty($queryString)) {
170 if (strpos($prefix, '?') === false) {
171 $prefix .= '?';
172 } else {
173 $prefix .= '&';
174 }
175 }
176 $uri = new Uri($prefix . $queryString);
177 if ($fragment) {
178 $uri = $uri->withFragment($fragment);
179 }
180 if ($type === self::ABSOLUTE_PATH) {
181 $uri = $uri->withScheme('')->withHost('')->withPort(null);
182 }
183 return $uri;
184 }
185
186 /**
187 * Check for records in the database which matches one of the slug candidates.
188 *
189 * @param array $slugCandidates
190 * @param int $languageId
191 * @return array
192 */
193 protected function getPagesFromDatabaseForCandidates(array $slugCandidates, int $languageId): array
194 {
195 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
196 ->getQueryBuilderForTable('pages');
197 $queryBuilder
198 ->getRestrictions()
199 ->removeAll()
200 ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
201 ->add(GeneralUtility::makeInstance(FrontendWorkspaceRestriction::class));
202
203 $statement = $queryBuilder
204 ->select('uid', 'l10n_parent', 'pid', 'slug')
205 ->from('pages')
206 ->where(
207 $queryBuilder->expr()->eq(
208 'sys_language_uid',
209 $queryBuilder->createNamedParameter($languageId, \PDO::PARAM_INT)
210 ),
211 $queryBuilder->expr()->in(
212 'slug',
213 $queryBuilder->createNamedParameter(
214 $slugCandidates,
215 Connection::PARAM_STR_ARRAY
216 )
217 )
218 )
219 // Exact match will be first, that's important
220 ->orderBy('slug', 'desc')
221 ->execute();
222
223 $pages = [];
224 $siteMatcher = GeneralUtility::makeInstance(SiteMatcher::class);
225 while ($row = $statement->fetch()) {
226 $pageIdInDefaultLanguage = (int)($languageId > 0 ? $row['l10n_parent'] : $row['uid']);
227 if ($siteMatcher->matchByPageId($pageIdInDefaultLanguage)->getRootPageId() === $this->site->getRootPageId()) {
228 $pages[] = $row;
229 }
230 }
231 return $pages;
232 }
233
234 /**
235 * Returns possible URL parts for a string like /home/about-us/offices/
236 * to return.
237 *
238 * /home/about-us/offices/
239 * /home/about-us/offices
240 * /home/about-us/
241 * /home/about-us
242 * /home/
243 * /home
244 *
245 * @param string $routePath
246 * @return array
247 */
248 protected function getCandidateSlugsFromRoutePath(string $routePath): array
249 {
250 $candidatePathParts = [];
251 $pathParts = GeneralUtility::trimExplode('/', $routePath, true);
252 while (!empty($pathParts)) {
253 $prefix = '/' . implode('/', $pathParts);
254 $candidatePathParts[] = $prefix . '/';
255 $candidatePathParts[] = $prefix;
256 array_pop($pathParts);
257 }
258 return $candidatePathParts;
259 }
260 }