‪TYPO3CMS  ‪main
RedirectService.php
Go to the documentation of this file.
1 <?php
2 
3 declare(strict_types=1);
4 
5 /*
6  * This file is part of the TYPO3 CMS project.
7  *
8  * It is free software; you can redistribute it and/or modify it under
9  * the terms of the GNU General Public License, either version 2
10  * of the License, or any later version.
11  *
12  * For the full copyright and license information, please read the
13  * LICENSE.txt file that was distributed with this source code.
14  *
15  * The TYPO3 project - inspiring people to share!
16  */
17 
19 
20 use Psr\EventDispatcher\EventDispatcherInterface;
21 use Psr\Http\Message\ServerRequestInterface;
22 use Psr\Http\Message\UriInterface;
23 use Psr\Log\LoggerInterface;
29 use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService;
37 use TYPO3\CMS\Core\TypoScript\FrontendTypoScriptFactory;
47 
54 {
55  public function ‪__construct(
56  private readonly ‪RedirectCacheService $redirectCacheService,
57  private readonly ‪LinkService $linkService,
58  private readonly ‪SiteFinder $siteFinder,
59  private readonly EventDispatcherInterface $eventDispatcher,
60  private readonly ‪PageInformationFactory $pageInformationFactory,
61  private readonly FrontendTypoScriptFactory $frontendTypoScriptFactory,
62  private readonly ‪PhpFrontend $typoScriptCache,
63  private readonly LoggerInterface $logger,
64  ) {}
65 
69  public function ‪matchRedirect(string $domain, string $path, string $query = ''): ?array
70  {
71  $path = rawurldecode($path);
72  // Check if the domain matches, or if there is a
73  // redirect fitting for any domain
74  foreach ([$domain, '*'] as $domainName) {
75  ‪$matchedRedirect = $this->eventDispatcher->dispatch(
77  $domain,
78  $path,
79  $query,
80  $domainName,
81  )
82  )->getMatchedRedirect();
83  if (‪$matchedRedirect !== null && ‪$matchedRedirect !== []) {
84  return ‪$matchedRedirect;
85  }
86  $redirects = $this->‪fetchRedirects($domainName);
87  if (empty($redirects)) {
88  continue;
89  }
90 
91  // check if a flat redirect matches with the Query applied
92  if (!empty($query)) {
93  $pathWithQuery = rtrim($path, '/') . '?' . ltrim($query, '?');
94  if (!empty($redirects['respect_query_parameters'][$pathWithQuery])) {
95  if (‪$matchedRedirect = $this->‪getFirstActiveRedirectFromPossibleRedirects($redirects['respect_query_parameters'][$pathWithQuery])) {
96  return ‪$matchedRedirect;
97  }
98  } else {
99  $pathWithQueryAndSlash = rtrim($path, '/') . '/?' . ltrim($query, '?');
100  if (!empty($redirects['respect_query_parameters'][$pathWithQueryAndSlash])) {
101  if (‪$matchedRedirect = $this->‪getFirstActiveRedirectFromPossibleRedirects($redirects['respect_query_parameters'][$pathWithQueryAndSlash])) {
102  return ‪$matchedRedirect;
103  }
104  }
105  }
106  }
107 
108  // check if a flat redirect matches
109  if (!empty($redirects['flat'][rtrim($path, '/') . '/'])) {
110  if (‪$matchedRedirect = $this->‪getFirstActiveRedirectFromPossibleRedirects($redirects['flat'][rtrim($path, '/') . '/'])) {
111  return ‪$matchedRedirect;
112  }
113  }
114 
115  // check all regex redirects respecting query arguments
116  if (!empty($redirects['regexp_query_parameters'])) {
117  $allRegexps = array_keys($redirects['regexp_query_parameters']);
118  $regExpPath = $path;
119  if (!empty($query)) {
120  $regExpPath .= '?' . ltrim($query, '?');
121  }
122  foreach ($allRegexps as $regexp) {
123  $matchResult = @preg_match((string)$regexp, $regExpPath);
124  if ($matchResult > 0) {
125  if (‪$matchedRedirect = $this->‪getFirstActiveRedirectFromPossibleRedirects($redirects['regexp_query_parameters'][$regexp])) {
126  return ‪$matchedRedirect;
127  }
128  continue;
129  }
130 
131  // Log invalid regular expression
132  if ($matchResult === false) {
133  $this->logger->warning('Invalid regex in redirect', ['regex' => $regexp]);
134  }
135  }
136  }
137 
138  // check all redirects that are registered as regex
139  if (!empty($redirects['regexp_flat'])) {
140  $allRegexps = array_keys($redirects['regexp_flat']);
141  $regExpPath = $path;
142  if (!empty($query)) {
143  $regExpPath .= '?' . ltrim($query, '?');
144  }
145  foreach ($allRegexps as $regexp) {
146  $matchResult = @preg_match((string)$regexp, $regExpPath);
147  if ($matchResult > 0) {
148  if (‪$matchedRedirect = $this->‪getFirstActiveRedirectFromPossibleRedirects($redirects['regexp_flat'][$regexp])) {
149  return ‪$matchedRedirect;
150  }
151  continue;
152  }
153 
154  // Log invalid regular expression
155  if ($matchResult === false) {
156  $this->logger->warning('Invalid regex in redirect', ['regex' => $regexp]);
157  }
158  }
159 
160  // We need a second match run to evaluate against path only, even when query parameters where
161  // provided to ensure regexp without query parameters in mind are still processed.
162  // We need to do this only if there are query parameters in the request, otherwise first
163  // preg_match would have found it.
164  if (!empty($query)) {
165  foreach ($allRegexps as $regexp) {
166  $matchResult = preg_match((string)$regexp, $path);
167  if ($matchResult > 0) {
168  if (‪$matchedRedirect = $this->‪getFirstActiveRedirectFromPossibleRedirects($redirects['regexp_flat'][$regexp])) {
169  return ‪$matchedRedirect;
170  }
171  }
172  }
173  }
174  }
175  }
176 
177  return null;
178  }
179 
185  protected function ‪isRedirectActive(array $redirectRecord): bool
186  {
187  return !$redirectRecord['disabled'] && $redirectRecord['starttime'] <= ‪$GLOBALS['SIM_ACCESS_TIME'] &&
188  (!$redirectRecord['endtime'] || $redirectRecord['endtime'] >= ‪$GLOBALS['SIM_ACCESS_TIME']);
189  }
190 
195  protected function ‪fetchRedirects(string $sourceHost): array
196  {
197  return $this->redirectCacheService->getRedirects($sourceHost);
198  }
199 
205  protected function ‪resolveLinkDetailsFromLinkTarget(string $redirectTarget): array
206  {
207  try {
208  $linkDetails = $this->linkService->resolve($redirectTarget);
209  // Having the `typoLinkParameter` in the linkDetails is required, if the linkDetails are used to generate
210  // an url out of it. Therefore, this should be set in `getUriFromCustomLinkDetails()` before calling the
211  // LinkBuilder->build() method. We have a really tight execution context here, so we can safely set it here
212  // for now.
213  // @todo This simply reflects the used value to resolve the details. Other places in core set this to the
214  // array before building an url. This looks kind of unfinished. We should check, if we should not set
215  // that linkDetail value directly in the LinkService()->resolve() method generally.
216  $linkDetails['typoLinkParameter'] = $redirectTarget;
217  switch ($linkDetails['type']) {
219  // all set up, nothing to do
220  break;
223  $file = $linkDetails['file'];
224  if ($file instanceof ‪File) {
225  $linkDetails['url'] = $file->getPublicUrl();
226  }
227  break;
230  $folder = $linkDetails['folder'];
231  if ($folder instanceof ‪Folder) {
232  $linkDetails['url'] = $folder->getPublicUrl();
233  }
234  break;
236  // If $redirectTarget could not be resolved, we can only assume $redirectTarget with leading '/'
237  // as relative redirect and try to resolve it with enriched information from current request.
238  // That ensures that regexp redirects ending in replaceRegExpCaptureGroup(), but also ensures
239  // that relative urls are not left as unknown file here.
240  if (str_starts_with($redirectTarget, '/')) {
241  $linkDetails = [
242  'type' => ‪LinkService::TYPE_URL,
243  'url' => $redirectTarget,
244  ];
245  }
246  break;
247  default:
248  // we have to return the link details without having a "URL" parameter
249  }
250  } catch (‪InvalidPathException $e) {
251  return [];
252  }
253 
254  return $linkDetails;
255  }
256 
257  public function ‪getTargetUrl(array ‪$matchedRedirect, ServerRequestInterface $request): ?UriInterface
258  {
259  $site = $request->getAttribute('site');
260  $uri = $request->getUri();
261  $queryParams = $request->getQueryParams();
262  $this->logger->debug('Found a redirect to process', ['redirect' => ‪$matchedRedirect]);
263  $linkParameterParts = GeneralUtility::makeInstance(TypoLinkCodecService::class)->decode((string)‪$matchedRedirect['target']);
264  $redirectTarget = $linkParameterParts['url'];
265  $linkDetails = $this->‪resolveLinkDetailsFromLinkTarget($redirectTarget);
266  $this->logger->debug('Resolved link details for redirect', ['details' => $linkDetails]);
267  if (!empty($linkParameterParts['additionalParams']) && ‪$matchedRedirect['keep_query_parameters']) {
268  $params = GeneralUtility::explodeUrl2Array($linkParameterParts['additionalParams']);
269  foreach ($params as $key => $value) {
270  $queryParams[$key] = $value;
271  }
272  }
273  // Do this for files, folders, external URLs or relative urls
274  if (!empty($linkDetails['url'])) {
275  if (‪$matchedRedirect['is_regexp'] ?? false) {
276  $linkDetails = $this->‪replaceRegExpCaptureGroup($matchedRedirect, $uri, $linkDetails);
277  }
278 
279  ‪$url = new ‪Uri($linkDetails['url']);
280  if (‪$matchedRedirect['force_https']) {
281  ‪$url = ‪$url->withScheme('https');
282  }
283  if (‪$matchedRedirect['keep_query_parameters']) {
284  ‪$url = $this->‪addQueryParams($queryParams, ‪$url);
285  }
286  return ‪$url;
287  }
288  $site = $this->‪resolveSite($linkDetails, $site);
289  // If it's a record or page, then boot up TSFE and use typolink
290  return $this->‪getUriFromCustomLinkDetails(
291  $matchedRedirect,
292  $site,
293  $linkDetails,
294  $queryParams,
295  $request
296  );
297  }
298 
302  protected function ‪resolveSite(array $linkDetails, ?‪SiteInterface $site): ?‪SiteInterface
303  {
304  if (($site === null || $site instanceof ‪NullSite) && ($linkDetails['type'] ?? '') === ‪LinkService::TYPE_PAGE) {
305  try {
306  return $this->siteFinder->getSiteByPageId((int)$linkDetails['pageuid']);
307  } catch (‪SiteNotFoundException $e) {
308  return new ‪NullSite();
309  }
310  }
311  return $site;
312  }
313 
317  protected function ‪addQueryParams(array $queryParams, ‪Uri ‪$url): ‪Uri
318  {
319  // New query parameters overrule the ones that should be kept
320  $newQueryParamString = ‪$url->getQuery();
321  if (!empty($newQueryParamString)) {
322  $newQueryParams = [];
323  parse_str($newQueryParamString, $newQueryParams);
324  $queryParams = array_replace_recursive($queryParams, $newQueryParams);
325  }
326  $query = http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986);
327  if ($query) {
328  ‪$url = ‪$url->withQuery($query);
329  }
330  return ‪$url;
331  }
332 
336  protected function ‪getUriFromCustomLinkDetails(array $redirectRecord, ?‪SiteInterface $site, array $linkDetails, array $queryParams, ServerRequestInterface $originalRequest): ?UriInterface
337  {
338  if (!isset($linkDetails['type'], ‪$GLOBALS['TYPO3_CONF_VARS']['FE']['typolinkBuilder'][$linkDetails['type']])) {
339  return null;
340  }
341  if ($site === null || $site instanceof ‪NullSite) {
342  return null;
343  }
344  $controller = $this->‪bootFrontendController($site, $queryParams, $originalRequest);
345  $linkBuilder = GeneralUtility::makeInstance(
346  ‪$GLOBALS['TYPO3_CONF_VARS']['FE']['typolinkBuilder'][$linkDetails['type']],
347  $controller->cObj,
348  $controller
349  );
350  if (!$linkBuilder instanceof ‪AbstractTypolinkBuilder) {
351  // @todo: Add a proper interface.
352  throw new \RuntimeException('Single link builder must extend AbstractTypolinkBuilder', 1646504471);
353  }
354  try {
355  $configuration = [
356  'parameter' => (string)$redirectRecord['target'],
357  'forceAbsoluteUrl' => true,
358  'linkAccessRestrictedPages' => true,
359  ];
360  if ($redirectRecord['force_https']) {
361  $configuration['forceAbsoluteUrl.']['scheme'] = 'https';
362  }
363  if ($redirectRecord['keep_query_parameters']) {
364  $configuration['additionalParams'] = ‪HttpUtility::buildQueryString($queryParams, '&');
365  }
366  $result = $linkBuilder->build($linkDetails, '', '', $configuration);
367  $this->‪cleanupTSFE();
368  return new ‪Uri($result->getUrl());
369  } catch (‪UnableToLinkException $e) {
370  $this->‪cleanupTSFE();
371  return null;
372  }
373  }
374 
392  protected function ‪bootFrontendController(‪SiteInterface $site, array $queryParams, ServerRequestInterface $originalRequest): ‪TypoScriptFrontendController
393  {
394  $context = GeneralUtility::makeInstance(Context::class);
395  $context->setAspect('frontend.preview', new ‪PreviewAspect());
396  $cacheInstruction = $originalRequest->getAttribute('frontend.cache.instruction', new ‪CacheInstruction());
397  $originalRequest = $originalRequest->withAttribute('frontend.cache.instruction', $cacheInstruction);
398  $queryParamsFromRequest = $originalRequest->getQueryParams();
399  $mergedQueryParams = array_merge($queryParams, $queryParamsFromRequest);
400  $originalRequest = $originalRequest->withQueryParams($mergedQueryParams);
401  $pageArguments = new ‪PageArguments($site->‪getRootPageId(), '0', []);
402  $originalRequest = $originalRequest->withAttribute('routing', $pageArguments);
403  $pageInformation = $this->pageInformationFactory->create($originalRequest);
404  $originalRequest = $originalRequest->withAttribute('frontend.page.information', $pageInformation);
405  $controller = GeneralUtility::makeInstance(TypoScriptFrontendController::class);
406  $controller->initializePageRenderer($originalRequest);
407  $expressionMatcherVariables = $this->‪getExpressionMatcherVariables($site, $originalRequest, $controller);
408  $frontendTypoScript = $this->frontendTypoScriptFactory->createSettingsAndSetupConditions(
409  $site,
410  $pageInformation->getSysTemplateRows(),
411  // $originalRequest does not contain site ...
412  $expressionMatcherVariables,
413  $this->typoScriptCache,
414  );
415  $frontendTypoScript = $this->frontendTypoScriptFactory->createSetupConfigOrFullSetup(
416  false,
417  $frontendTypoScript,
418  $site,
419  $pageInformation->getSysTemplateRows(),
420  $expressionMatcherVariables,
421  '0',
422  $this->typoScriptCache,
423  null
424  );
425  $newRequest = $originalRequest->withAttribute('frontend.typoscript', $frontendTypoScript);
426  $controller->newCObj($newRequest);
427  if (!isset(‪$GLOBALS['TSFE']) || !‪$GLOBALS['TSFE'] instanceof ‪TypoScriptFrontendController) {
428  ‪$GLOBALS['TSFE'] = $controller;
429  }
430  return $controller;
431  }
432 
433  private function ‪getExpressionMatcherVariables(‪SiteInterface $site, ServerRequestInterface $request, ‪TypoScriptFrontendController $controller): array
434  {
435  $pageInformation = $request->getAttribute('frontend.page.information');
436  $topDownRootLine = $pageInformation->getRootLine();
437  $localRootline = $pageInformation->getLocalRootLine();
438  ksort($topDownRootLine);
439  return [
440  'request' => $request,
441  'pageId' => $pageInformation->getId(),
442  'page' => $pageInformation->getPageRecord(),
443  'fullRootLine' => $topDownRootLine,
444  'localRootLine' => $localRootline,
445  'site' => $site,
446  'siteLanguage' => $request->getAttribute('language'),
447  'tsfe' => $controller,
448  ];
449  }
450 
451  protected function ‪replaceRegExpCaptureGroup(array ‪$matchedRedirect, UriInterface $uri, array $linkDetails): array
452  {
453  $uriToCheck = rawurldecode($uri->getPath());
454  if ((‪$matchedRedirect['respect_query_parameters'] ?? false) && $uri->getQuery()) {
455  $uriToCheck .= '?' . rawurldecode($uri->getQuery());
456  }
457  $matchResult = preg_match(‪$matchedRedirect['source_path'], $uriToCheck, $matches);
458  if ($matchResult > 0) {
459  foreach ($matches as $key => $val) {
460  // Unsafe regexp captching group may lead to adding query parameters to result url, which we need
461  // to prevent here, thus throwing everything beginning with ? away
462  if (str_contains($val, '?')) {
463  $val = explode('?', $val, 2)[0] ?? '';
464  $this->logger->warning(
465  sprintf(
466  'Unsafe captching group regex in redirect #%s, including query parameters in matched group',
467  ‪$matchedRedirect['uid'] ?? 0
468  ),
469  ['regex' => ‪$matchedRedirect['source_path']]
470  );
471  }
472  $linkDetails['url'] = str_replace('$' . $key, $val, $linkDetails['url']);
473  }
474  }
475  return $linkDetails;
476  }
477 
481  protected function ‪getFirstActiveRedirectFromPossibleRedirects(array $possibleRedirects): ?array
482  {
483  foreach ($possibleRedirects as $possibleRedirect) {
484  if ($this->‪isRedirectActive($possibleRedirect)) {
485  return $possibleRedirect;
486  }
487  }
488 
489  return null;
490  }
491 
497  private function ‪cleanupTSFE(): void
498  {
499  $context = GeneralUtility::makeInstance(Context::class);
500  $context->unsetAspect('language');
501  $context->unsetAspect('typoscript');
502  $context->unsetAspect('frontend.preview');
503  unset(‪$GLOBALS['TSFE']);
504  }
505 }
‪TYPO3\CMS\Redirects\Service\RedirectService\getFirstActiveRedirectFromPossibleRedirects
‪getFirstActiveRedirectFromPossibleRedirects(array $possibleRedirects)
Definition: RedirectService.php:481
‪TYPO3\CMS\Redirects\Service\RedirectService\getUriFromCustomLinkDetails
‪getUriFromCustomLinkDetails(array $redirectRecord, ?SiteInterface $site, array $linkDetails, array $queryParams, ServerRequestInterface $originalRequest)
Definition: RedirectService.php:336
‪TYPO3\CMS\Core\Site\Entity\SiteInterface
Definition: SiteInterface.php:26
‪TYPO3\CMS\Core\Routing\PageArguments
Definition: PageArguments.php:26
‪TYPO3\CMS\Redirects\Service\RedirectService\getExpressionMatcherVariables
‪getExpressionMatcherVariables(SiteInterface $site, ServerRequestInterface $request, TypoScriptFrontendController $controller)
Definition: RedirectService.php:433
‪TYPO3\CMS\Core\Cache\Frontend\PhpFrontend
Definition: PhpFrontend.php:25
‪TYPO3\CMS\Redirects\Service\RedirectService\isRedirectActive
‪bool isRedirectActive(array $redirectRecord)
Definition: RedirectService.php:185
‪TYPO3\CMS\Core\Site\Entity\NullSite
Definition: NullSite.php:32
‪TYPO3\CMS\Core\Exception\SiteNotFoundException
Definition: SiteNotFoundException.php:25
‪TYPO3\CMS\Redirects\Service\RedirectService
Definition: RedirectService.php:54
‪TYPO3\CMS\Core\Site\SiteFinder
Definition: SiteFinder.php:31
‪TYPO3\CMS\Redirects\Service\RedirectService\cleanupTSFE
‪cleanupTSFE()
Definition: RedirectService.php:497
‪TYPO3\CMS\Core\Context\Context
Definition: Context.php:54
‪TYPO3\CMS\Redirects\Service\RedirectService\__construct
‪__construct(private readonly RedirectCacheService $redirectCacheService, private readonly LinkService $linkService, private readonly SiteFinder $siteFinder, private readonly EventDispatcherInterface $eventDispatcher, private readonly PageInformationFactory $pageInformationFactory, private readonly FrontendTypoScriptFactory $frontendTypoScriptFactory, private readonly PhpFrontend $typoScriptCache, private readonly LoggerInterface $logger,)
Definition: RedirectService.php:55
‪TYPO3\CMS\Redirects\Service\RedirectService\getTargetUrl
‪getTargetUrl(array $matchedRedirect, ServerRequestInterface $request)
Definition: RedirectService.php:257
‪TYPO3\CMS\Core\Http\Uri
Definition: Uri.php:30
‪TYPO3\CMS\Redirects\Service\RedirectCacheService
Definition: RedirectCacheService.php:34
‪TYPO3\CMS\Core\Site\Entity\SiteInterface\getRootPageId
‪getRootPageId()
‪TYPO3\CMS\Redirects\Service\RedirectService\resolveLinkDetailsFromLinkTarget
‪array resolveLinkDetailsFromLinkTarget(string $redirectTarget)
Definition: RedirectService.php:205
‪TYPO3\CMS\Core\Resource\Folder
Definition: Folder.php:38
‪TYPO3\CMS\Core\Utility\HttpUtility\buildQueryString
‪static string buildQueryString(array $parameters, string $prependCharacter='', bool $skipEmptyParameters=false)
Definition: HttpUtility.php:124
‪TYPO3\CMS\Core\Resource\File
Definition: File.php:26
‪TYPO3\CMS\Redirects\Service\RedirectService\addQueryParams
‪addQueryParams(array $queryParams, Uri $url)
Definition: RedirectService.php:317
‪TYPO3\CMS\Redirects\Service\RedirectService\fetchRedirects
‪fetchRedirects(string $sourceHost)
Definition: RedirectService.php:195
‪TYPO3\CMS\Redirects\Service\RedirectService\matchRedirect
‪matchRedirect(string $domain, string $path, string $query='')
Definition: RedirectService.php:69
‪TYPO3\CMS\Webhooks\Message\$url
‪identifier readonly UriInterface $url
Definition: LoginErrorOccurredMessage.php:36
‪TYPO3\CMS\Redirects\Service
Definition: IntegrityService.php:18
‪TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController
Definition: TypoScriptFrontendController.php:58
‪$GLOBALS
‪$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['adminpanel']['modules']
Definition: ext_localconf.php:25
‪TYPO3\CMS\Redirects\Service\RedirectService\replaceRegExpCaptureGroup
‪replaceRegExpCaptureGroup(array $matchedRedirect, UriInterface $uri, array $linkDetails)
Definition: RedirectService.php:451
‪TYPO3\CMS\Redirects\Message\$matchedRedirect
‪identifier readonly UriInterface readonly int readonly array $matchedRedirect
Definition: RedirectWasHitMessage.php:35
‪TYPO3\CMS\Redirects\Service\RedirectService\resolveSite
‪resolveSite(array $linkDetails, ?SiteInterface $site)
Definition: RedirectService.php:302
‪TYPO3\CMS\Frontend\Cache\CacheInstruction
Definition: CacheInstruction.php:29
‪TYPO3\CMS\Core\Utility\HttpUtility
Definition: HttpUtility.php:24
‪TYPO3\CMS\Redirects\Service\RedirectService\bootFrontendController
‪bootFrontendController(SiteInterface $site, array $queryParams, ServerRequestInterface $originalRequest)
Definition: RedirectService.php:392
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:52
‪TYPO3\CMS\Frontend\Aspect\PreviewAspect
Definition: PreviewAspect.php:30
‪TYPO3\CMS\Core\Resource\Exception\InvalidPathException
Definition: InvalidPathException.php:23
‪TYPO3\CMS\Redirects\Event\BeforeRedirectMatchDomainEvent
Definition: BeforeRedirectMatchDomainEvent.php:28
‪TYPO3\CMS\Frontend\Page\PageInformationFactory
Definition: PageInformationFactory.php:63