‪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\LoggerAwareInterface;
24 use Psr\Log\LoggerAwareTrait;
29 use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService;
46 
52 class ‪RedirectService implements LoggerAwareInterface
53 {
54  use LoggerAwareTrait;
55 
56  public function ‪__construct(
57  private readonly ‪RedirectCacheService $redirectCacheService,
58  private readonly ‪LinkService $linkService,
59  private readonly ‪SiteFinder $siteFinder,
60  private readonly EventDispatcherInterface $eventDispatcher,
61  private readonly ‪PageInformationFactory $pageInformationFactory,
62  ) {}
63 
67  public function ‪matchRedirect(string $domain, string $path, string $query = ''): ?array
68  {
69  $path = rawurldecode($path);
70  // Check if the domain matches, or if there is a
71  // redirect fitting for any domain
72  foreach ([$domain, '*'] as $domainName) {
73  ‪$matchedRedirect = $this->eventDispatcher->dispatch(
75  $domain,
76  $path,
77  $query,
78  $domainName,
79  )
80  )->getMatchedRedirect();
81  if (‪$matchedRedirect !== null && ‪$matchedRedirect !== []) {
82  return ‪$matchedRedirect;
83  }
84  $redirects = $this->‪fetchRedirects($domainName);
85  if (empty($redirects)) {
86  continue;
87  }
88 
89  // check if a flat redirect matches with the Query applied
90  if (!empty($query)) {
91  $pathWithQuery = rtrim($path, '/') . '?' . ltrim($query, '?');
92  if (!empty($redirects['respect_query_parameters'][$pathWithQuery])) {
93  if (‪$matchedRedirect = $this->‪getFirstActiveRedirectFromPossibleRedirects($redirects['respect_query_parameters'][$pathWithQuery])) {
94  return ‪$matchedRedirect;
95  }
96  } else {
97  $pathWithQueryAndSlash = rtrim($path, '/') . '/?' . ltrim($query, '?');
98  if (!empty($redirects['respect_query_parameters'][$pathWithQueryAndSlash])) {
99  if (‪$matchedRedirect = $this->‪getFirstActiveRedirectFromPossibleRedirects($redirects['respect_query_parameters'][$pathWithQueryAndSlash])) {
100  return ‪$matchedRedirect;
101  }
102  }
103  }
104  }
105 
106  // check if a flat redirect matches
107  if (!empty($redirects['flat'][rtrim($path, '/') . '/'])) {
108  if (‪$matchedRedirect = $this->‪getFirstActiveRedirectFromPossibleRedirects($redirects['flat'][rtrim($path, '/') . '/'])) {
109  return ‪$matchedRedirect;
110  }
111  }
112 
113  // check all regex redirects respecting query arguments
114  if (!empty($redirects['regexp_query_parameters'])) {
115  $allRegexps = array_keys($redirects['regexp_query_parameters']);
116  $regExpPath = $path;
117  if (!empty($query)) {
118  $regExpPath .= '?' . ltrim($query, '?');
119  }
120  foreach ($allRegexps as $regexp) {
121  $matchResult = @preg_match((string)$regexp, $regExpPath);
122  if ($matchResult > 0) {
123  if (‪$matchedRedirect = $this->‪getFirstActiveRedirectFromPossibleRedirects($redirects['regexp_query_parameters'][$regexp])) {
124  return ‪$matchedRedirect;
125  }
126  continue;
127  }
128 
129  // Log invalid regular expression
130  if ($matchResult === false) {
131  $this->logger->warning('Invalid regex in redirect', ['regex' => $regexp]);
132  }
133  }
134  }
135 
136  // check all redirects that are registered as regex
137  if (!empty($redirects['regexp_flat'])) {
138  $allRegexps = array_keys($redirects['regexp_flat']);
139  $regExpPath = $path;
140  if (!empty($query)) {
141  $regExpPath .= '?' . ltrim($query, '?');
142  }
143  foreach ($allRegexps as $regexp) {
144  $matchResult = @preg_match((string)$regexp, $regExpPath);
145  if ($matchResult > 0) {
146  if (‪$matchedRedirect = $this->‪getFirstActiveRedirectFromPossibleRedirects($redirects['regexp_flat'][$regexp])) {
147  return ‪$matchedRedirect;
148  }
149  continue;
150  }
151 
152  // Log invalid regular expression
153  if ($matchResult === false) {
154  $this->logger->warning('Invalid regex in redirect', ['regex' => $regexp]);
155  }
156  }
157 
158  // We need a second match run to evaluate against path only, even when query parameters where
159  // provided to ensure regexp without query parameters in mind are still processed.
160  // We need to do this only if there are query parameters in the request, otherwise first
161  // preg_match would have found it.
162  if (!empty($query)) {
163  foreach ($allRegexps as $regexp) {
164  $matchResult = preg_match((string)$regexp, $path);
165  if ($matchResult > 0) {
166  if (‪$matchedRedirect = $this->‪getFirstActiveRedirectFromPossibleRedirects($redirects['regexp_flat'][$regexp])) {
167  return ‪$matchedRedirect;
168  }
169  }
170  }
171  }
172  }
173  }
174 
175  return null;
176  }
177 
183  protected function ‪isRedirectActive(array $redirectRecord): bool
184  {
185  return !$redirectRecord['disabled'] && $redirectRecord['starttime'] <= ‪$GLOBALS['SIM_ACCESS_TIME'] &&
186  (!$redirectRecord['endtime'] || $redirectRecord['endtime'] >= ‪$GLOBALS['SIM_ACCESS_TIME']);
187  }
188 
193  protected function ‪fetchRedirects(string $sourceHost): array
194  {
195  return $this->redirectCacheService->getRedirects($sourceHost);
196  }
197 
203  protected function ‪resolveLinkDetailsFromLinkTarget(string $redirectTarget): array
204  {
205  try {
206  $linkDetails = $this->linkService->resolve($redirectTarget);
207  // Having the `typoLinkParameter` in the linkDetails is required, if the linkDetails are used to generate
208  // an url out of it. Therefore, this should be set in `getUriFromCustomLinkDetails()` before calling the
209  // LinkBuilder->build() method. We have a really tight execution context here, so we can safely set it here
210  // for now.
211  // @todo This simply reflects the used value to resolve the details. Other places in core set this to the
212  // array before building an url. This looks kind of unfinished. We should check, if we should not set
213  // that linkDetail value directly in the LinkService()->resolve() method generally.
214  $linkDetails['typoLinkParameter'] = $redirectTarget;
215  switch ($linkDetails['type']) {
217  // all set up, nothing to do
218  break;
221  $file = $linkDetails['file'];
222  if ($file instanceof ‪File) {
223  $linkDetails['url'] = $file->getPublicUrl();
224  }
225  break;
228  $folder = $linkDetails['folder'];
229  if ($folder instanceof ‪Folder) {
230  $linkDetails['url'] = $folder->getPublicUrl();
231  }
232  break;
234  // If $redirectTarget could not be resolved, we can only assume $redirectTarget with leading '/'
235  // as relative redirect and try to resolve it with enriched information from current request.
236  // That ensures that regexp redirects ending in replaceRegExpCaptureGroup(), but also ensures
237  // that relative urls are not left as unknown file here.
238  if (str_starts_with($redirectTarget, '/')) {
239  $linkDetails = [
240  'type' => ‪LinkService::TYPE_URL,
241  'url' => $redirectTarget,
242  ];
243  }
244  break;
245  default:
246  // we have to return the link details without having a "URL" parameter
247  }
248  } catch (‪InvalidPathException $e) {
249  return [];
250  }
251 
252  return $linkDetails;
253  }
254 
255  public function ‪getTargetUrl(array ‪$matchedRedirect, ServerRequestInterface $request): ?UriInterface
256  {
257  $site = $request->getAttribute('site');
258  $uri = $request->getUri();
259  $queryParams = $request->getQueryParams();
260  $this->logger->debug('Found a redirect to process', ['redirect' => ‪$matchedRedirect]);
261  $linkParameterParts = GeneralUtility::makeInstance(TypoLinkCodecService::class)->decode((string)‪$matchedRedirect['target']);
262  $redirectTarget = $linkParameterParts['url'];
263  $linkDetails = $this->‪resolveLinkDetailsFromLinkTarget($redirectTarget);
264  $this->logger->debug('Resolved link details for redirect', ['details' => $linkDetails]);
265  if (!empty($linkParameterParts['additionalParams']) && ‪$matchedRedirect['keep_query_parameters']) {
266  $params = GeneralUtility::explodeUrl2Array($linkParameterParts['additionalParams']);
267  foreach ($params as $key => $value) {
268  $queryParams[$key] = $value;
269  }
270  }
271  // Do this for files, folders, external URLs or relative urls
272  if (!empty($linkDetails['url'])) {
273  if (‪$matchedRedirect['is_regexp'] ?? false) {
274  $linkDetails = $this->‪replaceRegExpCaptureGroup($matchedRedirect, $uri, $linkDetails);
275  }
276 
277  ‪$url = new ‪Uri($linkDetails['url']);
278  if (‪$matchedRedirect['force_https']) {
279  ‪$url = ‪$url->withScheme('https');
280  }
281  if (‪$matchedRedirect['keep_query_parameters']) {
282  ‪$url = $this->‪addQueryParams($queryParams, ‪$url);
283  }
284  return ‪$url;
285  }
286  $site = $this->‪resolveSite($linkDetails, $site);
287  // If it's a record or page, then boot up TSFE and use typolink
288  return $this->‪getUriFromCustomLinkDetails(
289  $matchedRedirect,
290  $site,
291  $linkDetails,
292  $queryParams,
293  $request
294  );
295  }
296 
300  protected function ‪resolveSite(array $linkDetails, ?‪SiteInterface $site): ?‪SiteInterface
301  {
302  if (($site === null || $site instanceof ‪NullSite) && ($linkDetails['type'] ?? '') === ‪LinkService::TYPE_PAGE) {
303  try {
304  return $this->siteFinder->getSiteByPageId((int)$linkDetails['pageuid']);
305  } catch (‪SiteNotFoundException $e) {
306  return new ‪NullSite();
307  }
308  }
309  return $site;
310  }
311 
315  protected function ‪addQueryParams(array $queryParams, ‪Uri ‪$url): ‪Uri
316  {
317  // New query parameters overrule the ones that should be kept
318  $newQueryParamString = ‪$url->getQuery();
319  if (!empty($newQueryParamString)) {
320  $newQueryParams = [];
321  parse_str($newQueryParamString, $newQueryParams);
322  $queryParams = array_replace_recursive($queryParams, $newQueryParams);
323  }
324  $query = http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986);
325  if ($query) {
326  ‪$url = ‪$url->withQuery($query);
327  }
328  return ‪$url;
329  }
330 
334  protected function ‪getUriFromCustomLinkDetails(array $redirectRecord, ?‪SiteInterface $site, array $linkDetails, array $queryParams, ServerRequestInterface $originalRequest): ?UriInterface
335  {
336  if (!isset($linkDetails['type'], ‪$GLOBALS['TYPO3_CONF_VARS']['FE']['typolinkBuilder'][$linkDetails['type']])) {
337  return null;
338  }
339  if ($site === null || $site instanceof ‪NullSite) {
340  return null;
341  }
342  $controller = $this->‪bootFrontendController($site, $queryParams, $originalRequest);
343  $linkBuilder = GeneralUtility::makeInstance(
344  ‪$GLOBALS['TYPO3_CONF_VARS']['FE']['typolinkBuilder'][$linkDetails['type']],
345  $controller->cObj,
346  $controller
347  );
348  if (!$linkBuilder instanceof ‪AbstractTypolinkBuilder) {
349  // @todo: Add a proper interface.
350  throw new \RuntimeException('Single link builder must extend AbstractTypolinkBuilder', 1646504471);
351  }
352  try {
353  $configuration = [
354  'parameter' => (string)$redirectRecord['target'],
355  'forceAbsoluteUrl' => true,
356  'linkAccessRestrictedPages' => true,
357  ];
358  if ($redirectRecord['force_https']) {
359  $configuration['forceAbsoluteUrl.']['scheme'] = 'https';
360  }
361  if ($redirectRecord['keep_query_parameters']) {
362  $configuration['additionalParams'] = ‪HttpUtility::buildQueryString($queryParams, '&');
363  }
364  $result = $linkBuilder->build($linkDetails, '', '', $configuration);
365  $this->‪cleanupTSFE();
366  return new ‪Uri($result->getUrl());
367  } catch (‪UnableToLinkException $e) {
368  $this->‪cleanupTSFE();
369  return null;
370  }
371  }
372 
390  protected function ‪bootFrontendController(‪SiteInterface $site, array $queryParams, ServerRequestInterface $originalRequest): ‪TypoScriptFrontendController
391  {
392  $context = GeneralUtility::makeInstance(Context::class);
393  $context->setAspect('frontend.preview', new ‪PreviewAspect());
394  $cacheInstruction = $originalRequest->getAttribute('frontend.cache.instruction', new ‪CacheInstruction());
395  $originalRequest = $originalRequest->withAttribute('frontend.cache.instruction', $cacheInstruction);
396  $queryParamsFromRequest = $originalRequest->getQueryParams();
397  $mergedQueryParams = array_merge($queryParams, $queryParamsFromRequest);
398  $originalRequest = $originalRequest->withQueryParams($mergedQueryParams);
399  $pageArguments = new ‪PageArguments($site->‪getRootPageId(), '0', []);
400  $originalRequest = $originalRequest->withAttribute('routing', $pageArguments);
401  $pageInformation = $this->pageInformationFactory->create($originalRequest);
402  $originalRequest = $originalRequest->withAttribute('frontend.page.information', $pageInformation);
403  $controller = GeneralUtility::makeInstance(TypoScriptFrontendController::class);
404  $controller->initializePageRenderer($originalRequest);
405  $frontendTypoScript = $controller->getFromCache($originalRequest);
406  $newRequest = $originalRequest->withAttribute('frontend.typoscript', $frontendTypoScript);
407  $controller->releaseLocks();
408  $controller->newCObj($newRequest);
409  if (!isset(‪$GLOBALS['TSFE']) || !‪$GLOBALS['TSFE'] instanceof ‪TypoScriptFrontendController) {
410  ‪$GLOBALS['TSFE'] = $controller;
411  }
412  return $controller;
413  }
414 
415  protected function ‪replaceRegExpCaptureGroup(array ‪$matchedRedirect, UriInterface $uri, array $linkDetails): array
416  {
417  $uriToCheck = rawurldecode($uri->getPath());
418  if ((‪$matchedRedirect['respect_query_parameters'] ?? false) && $uri->getQuery()) {
419  $uriToCheck .= '?' . rawurldecode($uri->getQuery());
420  }
421  $matchResult = preg_match(‪$matchedRedirect['source_path'], $uriToCheck, $matches);
422  if ($matchResult > 0) {
423  foreach ($matches as $key => $val) {
424  // Unsafe regexp captching group may lead to adding query parameters to result url, which we need
425  // to prevent here, thus throwing everything beginning with ? away
426  if (str_contains($val, '?')) {
427  $val = explode('?', $val, 2)[0] ?? '';
428  $this->logger->warning(
429  sprintf(
430  'Unsafe captching group regex in redirect #%s, including query parameters in matched group',
431  ‪$matchedRedirect['uid'] ?? 0
432  ),
433  ['regex' => ‪$matchedRedirect['source_path']]
434  );
435  }
436  $linkDetails['url'] = str_replace('$' . $key, $val, $linkDetails['url']);
437  }
438  }
439  return $linkDetails;
440  }
441 
445  protected function ‪getFirstActiveRedirectFromPossibleRedirects(array $possibleRedirects): ?array
446  {
447  foreach ($possibleRedirects as $possibleRedirect) {
448  if ($this->‪isRedirectActive($possibleRedirect)) {
449  return $possibleRedirect;
450  }
451  }
452 
453  return null;
454  }
455 
461  private function ‪cleanupTSFE(): void
462  {
463  $context = GeneralUtility::makeInstance(Context::class);
464  $context->unsetAspect('language');
465  $context->unsetAspect('typoscript');
466  $context->unsetAspect('frontend.preview');
467  unset(‪$GLOBALS['TSFE']);
468  }
469 }
‪TYPO3\CMS\Redirects\Service\RedirectService\getFirstActiveRedirectFromPossibleRedirects
‪getFirstActiveRedirectFromPossibleRedirects(array $possibleRedirects)
Definition: RedirectService.php:445
‪TYPO3\CMS\Redirects\Service\RedirectService\getUriFromCustomLinkDetails
‪getUriFromCustomLinkDetails(array $redirectRecord, ?SiteInterface $site, array $linkDetails, array $queryParams, ServerRequestInterface $originalRequest)
Definition: RedirectService.php:334
‪TYPO3\CMS\Core\Site\Entity\SiteInterface
Definition: SiteInterface.php:26
‪TYPO3\CMS\Core\Routing\PageArguments
Definition: PageArguments.php:26
‪TYPO3\CMS\Redirects\Service\RedirectService\isRedirectActive
‪bool isRedirectActive(array $redirectRecord)
Definition: RedirectService.php:183
‪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:53
‪TYPO3\CMS\Core\Site\SiteFinder
Definition: SiteFinder.php:31
‪TYPO3\CMS\Redirects\Service\RedirectService\cleanupTSFE
‪cleanupTSFE()
Definition: RedirectService.php:461
‪TYPO3\CMS\Core\Context\Context
Definition: Context.php:54
‪TYPO3\CMS\Redirects\Service\RedirectService\getTargetUrl
‪getTargetUrl(array $matchedRedirect, ServerRequestInterface $request)
Definition: RedirectService.php:255
‪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:203
‪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:315
‪TYPO3\CMS\Redirects\Service\RedirectService\fetchRedirects
‪fetchRedirects(string $sourceHost)
Definition: RedirectService.php:193
‪TYPO3\CMS\Redirects\Service\RedirectService\matchRedirect
‪matchRedirect(string $domain, string $path, string $query='')
Definition: RedirectService.php:67
‪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:82
‪$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:415
‪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:300
‪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:390
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:52
‪TYPO3\CMS\Frontend\Aspect\PreviewAspect
Definition: PreviewAspect.php:30
‪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,)
Definition: RedirectService.php:56
‪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:62