[BUGFIX] Transform language parameter when previewing translated pages
[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 * A page router is always bound to a specific site.
70 *
71 * @param Site $site
72 */
73 public function __construct(Site $site)
74 {
75 $this->site = $site;
76 }
77
78 /**
79 * Finds a RouteResult based on the given request.
80 *
81 * @param ServerRequestInterface $request
82 * @param RouteResultInterface|RouteResult|null $previousResult
83 * @return RouteResult
84 */
85 public function matchRequest(ServerRequestInterface $request, RouteResultInterface $previousResult = null): ?RouteResultInterface
86 {
87 $slugCandidates = $this->getCandidateSlugsFromRoutePath($previousResult->getTail());
88 if (empty($slugCandidates)) {
89 return null;
90 }
91 $language = $previousResult->getLanguage();
92 $pageCandidates = $this->getPagesFromDatabaseForCandidates($slugCandidates, $language->getLanguageId());
93 // Stop if there are no candidates
94 if (empty($pageCandidates)) {
95 return null;
96 }
97
98 $fullCollection = new RouteCollection();
99 foreach ($pageCandidates ?? [] as $page) {
100 $pagePath = $page['slug'];
101 $defaultRouteForPage = new Route(
102 $pagePath . '{tail}',
103 ['tail' => ''],
104 ['tail' => '.*'],
105 ['utf8' => true, '_page' => $page]
106 );
107 $fullCollection->add('page_' . $page['uid'], $defaultRouteForPage);
108 }
109
110 $context = new RequestContext('/', $request->getMethod(), $request->getUri()->getHost());
111 $matcher = new UrlMatcher($fullCollection, $context);
112 try {
113 $result = $matcher->match('/' . ltrim($previousResult->getTail(), '/'));
114 /** @var Route $matchedRoute */
115 $matchedRoute = $fullCollection->get($result['_route']);
116 unset($result['_route']);
117 return $this->buildRouteResult($request, $language, $matchedRoute, $result);
118 } catch (ResourceNotFoundException $e) {
119 // return nothing
120 }
121 return null;
122 }
123
124 /**
125 * API for generating a page where the $route parameter is typically an array (page record) or the page ID
126 *
127 * @param array|string $route
128 * @param array $parameters an array of query parameters which can be built into the URI path, also consider the special handling of "_language"
129 * @param string $fragment additional #my-fragment part
130 * @param string $type see the RouterInterface for possible types
131 * @return UriInterface
132 */
133 public function generateUri($route, array $parameters = [], string $fragment = '', string $type = ''): UriInterface
134 {
135 // Resolve language
136 $language = null;
137 $languageOption = $parameters['_language'] ?? null;
138 unset($parameters['_language']);
139 if ($languageOption instanceof SiteLanguage) {
140 $language = $languageOption;
141 } elseif ($languageOption !== null) {
142 $language = $this->site->getLanguageById((int)$languageOption);
143 }
144 if ($language === null) {
145 $language = $this->site->getDefaultLanguage();
146 }
147
148 $pageId = 0;
149 if (is_array($route)) {
150 $pageId = (int)$route['uid'];
151 } elseif (is_scalar($route)) {
152 $pageId = (int)$route;
153 }
154
155 $context = clone GeneralUtility::makeInstance(Context::class);
156 $context->setAspect('language', new LanguageAspect($language->getLanguageId()));
157 $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context);
158 $page = $pageRepository->getPage($pageId, true);
159 $pagePath = ltrim($page['slug'] ?? '', '/');
160
161 $prefix = (string)$language->getBase();
162 $prefix = rtrim($prefix, '/') . '/' . $pagePath;
163
164 // Add the query parameters as string
165 $queryString = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
166 $prefix = rtrim($prefix, '?');
167 if (!empty($queryString)) {
168 if (strpos($prefix, '?') === false) {
169 $prefix .= '?';
170 } else {
171 $prefix .= '&';
172 }
173 }
174 $uri = new Uri($prefix . $queryString);
175 if ($fragment) {
176 $uri = $uri->withFragment($fragment);
177 }
178 if ($type === RouterInterface::ABSOLUTE_PATH) {
179 $uri = $uri->withScheme('')->withHost('')->withPort(null);
180 }
181 return $uri;
182 }
183
184 /**
185 * Check for records in the database which matches one of the slug candidates.
186 *
187 * @param array $slugCandidates
188 * @param int $languageId
189 * @return array
190 */
191 protected function getPagesFromDatabaseForCandidates(array $slugCandidates, int $languageId): array
192 {
193 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
194 ->getQueryBuilderForTable('pages');
195 $queryBuilder
196 ->getRestrictions()
197 ->removeAll()
198 ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
199 ->add(GeneralUtility::makeInstance(FrontendWorkspaceRestriction::class));
200
201 $statement = $queryBuilder
202 ->select('uid', 'l10n_parent', 'pid', 'slug')
203 ->from('pages')
204 ->where(
205 $queryBuilder->expr()->eq(
206 'sys_language_uid',
207 $queryBuilder->createNamedParameter($languageId, \PDO::PARAM_INT)
208 ),
209 $queryBuilder->expr()->in(
210 'slug',
211 $queryBuilder->createNamedParameter(
212 $slugCandidates,
213 Connection::PARAM_STR_ARRAY
214 )
215 )
216 )
217 // Exact match will be first, that's important
218 ->orderBy('slug', 'desc')
219 ->execute();
220
221 $pages = [];
222 $siteMatcher = GeneralUtility::makeInstance(SiteMatcher::class);
223 while ($row = $statement->fetch()) {
224 $pageIdInDefaultLanguage = (int)($languageId > 0 ? $row['l10n_parent'] : $row['uid']);
225 if ($siteMatcher->matchByPageId($pageIdInDefaultLanguage)->getRootPageId() === $this->site->getRootPageId()) {
226 $pages[] = $row;
227 }
228 }
229 return $pages;
230 }
231
232 /**
233 * Returns possible URL parts for a string like /home/about-us/offices/
234 * to return.
235 *
236 * /home/about-us/offices/
237 * /home/about-us/offices
238 * /home/about-us/
239 * /home/about-us
240 * /home/
241 * /home
242 *
243 * @param string $routePath
244 * @return array
245 */
246 protected function getCandidateSlugsFromRoutePath(string $routePath): array
247 {
248 $candidatePathParts = [];
249 $pathParts = GeneralUtility::trimExplode('/', $routePath, true);
250 while (!empty($pathParts)) {
251 $prefix = '/' . implode('/', $pathParts);
252 $candidatePathParts[] = $prefix . '/';
253 $candidatePathParts[] = $prefix;
254 array_pop($pathParts);
255 }
256 return $candidatePathParts;
257 }
258
259 /**
260 * @param ServerRequestInterface $request
261 * @param SiteLanguage|null $language
262 * @param Route|null $route
263 * @param array $results
264 * @return RouteResult
265 */
266 protected function buildRouteResult(ServerRequestInterface $request, SiteLanguage $language, Route $route, array $results = []): RouteResult
267 {
268 $data = [];
269 // page record the route has been applied for
270 if ($route->hasOption('_page')) {
271 $data['page'] = $route->getOption('_page');
272 }
273 $tail = $results['tail'] ?? '';
274 return new RouteResult($request->getUri(), $this->site, $language, $tail, $data);
275 }
276 }