diff --git a/Classes/Event/Listener/AfterLinkIsGeneratedListener.php b/Classes/Event/Listener/AfterLinkIsGeneratedListener.php index 7d587865..f91601e8 100644 --- a/Classes/Event/Listener/AfterLinkIsGeneratedListener.php +++ b/Classes/Event/Listener/AfterLinkIsGeneratedListener.php @@ -12,10 +12,18 @@ namespace FriendsOfTYPO3\Headless\Event\Listener; use FriendsOfTYPO3\Headless\Utility\HeadlessFrontendUrlInterface; +use Psr\Log\LoggerInterface; +use Throwable; +use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException; use TYPO3\CMS\Core\LinkHandling\LinkService; -use TYPO3\CMS\Core\Site\Entity\NullSite; +use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService; +use TYPO3\CMS\Core\Resource\Exception\InvalidPathException; use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\SiteFinder; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; use TYPO3\CMS\Frontend\Event\AfterLinkIsGeneratedEvent; +use TYPO3\CMS\Frontend\Typolink\UnableToLinkException; use function is_numeric; use function is_string; @@ -24,8 +32,11 @@ final class AfterLinkIsGeneratedListener { public function __construct( + private readonly LoggerInterface $logger, private readonly HeadlessFrontendUrlInterface $urlUtility, - private readonly LinkService $linkService + private readonly LinkService $linkService, + private readonly TypoLinkCodecService $typoLinkCodecService, + private readonly SiteFinder $siteFinder ) {} public function __invoke(AfterLinkIsGeneratedEvent $event): void @@ -50,18 +61,17 @@ public function __invoke(AfterLinkIsGeneratedEvent $event): void (int)$pageId ); } else { - /** - * @var Site $site - */ - $site = $event->getContentObjectRenderer()->getRequest()->getAttribute('site'); - $key = 'frontendBase'; + try { + $site = $this->getTargetSite($event); + $key = 'frontendBase'; - if (!$site instanceof NullSite) { if (is_string($pageId) && str_starts_with($pageId, 't3://page?uid=current&type=' . $site->getSettings()->get('headless.sitemap.type', '1533906435'))) { $key = $site->getSettings()->get('headless.sitemap.key', 'frontendApiProxy'); } $href = $urlUtility->getFrontendUrlWithSite($event->getLinkResult()->getUrl(), $site, $key); + } catch (Throwable $e) { + $this->logger->error($e->getMessage()); } } @@ -70,4 +80,92 @@ public function __invoke(AfterLinkIsGeneratedEvent $event): void $event->setLinkResult($result); } } + + private function getTargetSite(AfterLinkIsGeneratedEvent $event): Site + { + $linkConfiguration = $event->getLinkResult()->getLinkConfiguration(); + + if (isset($linkConfiguration['parameter.'])) { + // Evaluate "parameter." stdWrap but keep additional information (like target, class and title) + $linkParameterParts = $this->typoLinkCodecService->decode($linkConfiguration['parameter'] ?? ''); + $modifiedLinkParameterString = $event->getContentObjectRenderer()->stdWrap($linkParameterParts['url'], $linkConfiguration['parameter.']); + // As the stdWrap result might contain target etc. as well again (".field = header_link") + // the result is then taken from the stdWrap and overridden if the value is not empty. + $modifiedLinkParameterParts = $this->typoLinkCodecService->decode((string)($modifiedLinkParameterString ?? '')); + $linkParameterParts = array_replace($linkParameterParts, array_filter($modifiedLinkParameterParts, static fn($value) => trim((string)$value) !== '')); + $linkParameter = $this->typoLinkCodecService->encode($linkParameterParts); + } else { + $linkParameter = trim((string)($linkConfiguration['parameter'] ?? '')); + } + + try { + [$linkParameter] = $this->resolveTypolinkParameterString($linkParameter, $linkConfiguration); + } catch (UnableToLinkException $e) { + $this->logger->warning($e->getMessage(), ['linkConfiguration' => $linkConfiguration]); + throw $e; + } + $linkDetails = $this->resolveLinkDetails($linkParameter, $linkConfiguration, $event->getContentObjectRenderer()); + if ($linkDetails === null) { + throw new UnableToLinkException('Could not resolve link details from ' . $linkParameter, 1642001442, null, $event->getLinkResult()->getLinkText()); + } + + if (($linkDetails['pageuid'] ?? '') === 'current') { + return $event->getContentObjectRenderer()->getRequest()->getAttribute('site'); + } + + return $this->siteFinder->getSiteByPageId((int)$linkDetails['pageuid']); + } + + protected function resolveLinkDetails(string $linkParameter, array $linkConfiguration, ContentObjectRenderer $contentObjectRenderer): ?array + { + $linkDetails = null; + if (!$linkParameter) { + // Support anchors without href value if id or name attribute is present. + $aTagParams = (string)$contentObjectRenderer->stdWrapValue('ATagParams', $linkConfiguration); + $aTagParams = GeneralUtility::get_tag_attributes($aTagParams); + // If it looks like an anchor tag, render it anyway + if (isset($aTagParams['id']) || isset($aTagParams['name'])) { + $linkDetails = [ + 'type' => LinkService::TYPE_INPAGE, + 'url' => '', + ]; + } + } else { + // Detecting kind of link and resolve all necessary parameters + try { + $linkDetails = $this->linkService->resolve($linkParameter); + } catch (UnknownLinkHandlerException|InvalidPathException $exception) { + $this->logger->warning('The link could not be generated', ['exception' => $exception]); + return null; + } + } + if (is_array($linkDetails)) { + $linkDetails['typoLinkParameter'] = $linkParameter; + } + return $linkDetails; + } + + private function resolveTypolinkParameterString(string $mixedLinkParameter, array &$linkConfiguration = []): array + { + $linkParameterParts = $this->typoLinkCodecService->decode($mixedLinkParameter); + [$linkHandlerKeyword] = explode(':', $linkParameterParts['url'] ?? '', 2); + if (in_array(strtolower((string)preg_replace('#\s|[[:cntrl:]]#', '', (string)$linkHandlerKeyword)), ['javascript', 'data'], true)) { + // Disallow insecure scheme's like javascript: or data: + throw new UnableToLinkException('Insuecure scheme for linking detected with "' . $mixedLinkParameter . "'", 1641986533); + } + + // additional parameters that need to be set + if (($linkParameterParts['additionalParams'] ?? '') !== '') { + $forceParams = $linkParameterParts['additionalParams']; + // params value + $linkConfiguration['additionalParams'] = ($linkConfiguration['additionalParams'] ?? '') . $forceParams[0] === '&' ? $forceParams : '&' . $forceParams; + } + + return [ + $linkParameterParts['url'] ?? '', + $linkParameterParts['target'] ?? '', + $linkParameterParts['class'] ?? '', + $linkParameterParts['title'] ?? '', + ]; + } } diff --git a/Tests/Unit/Event/Listener/AfterLinkIsGeneratedListenerTest.php b/Tests/Unit/Event/Listener/AfterLinkIsGeneratedListenerTest.php index c3fc744d..f5397f48 100644 --- a/Tests/Unit/Event/Listener/AfterLinkIsGeneratedListenerTest.php +++ b/Tests/Unit/Event/Listener/AfterLinkIsGeneratedListenerTest.php @@ -16,6 +16,8 @@ use TYPO3\CMS\Core\ExpressionLanguage\Resolver; use TYPO3\CMS\Core\Http\ServerRequest; use TYPO3\CMS\Core\LinkHandling\LinkService; +use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService; +use TYPO3\CMS\Core\Log\Logger; use TYPO3\CMS\Core\Site\Entity\Site; use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; @@ -34,8 +36,11 @@ public function test__construct() $siteFinder = $this->prophesize(SiteFinder::class); $listener = new AfterLinkIsGeneratedListener( + $this->prophesize(Logger::class)->reveal(), new UrlUtility(null, $resolver->reveal(), $siteFinder->reveal()), - $this->prophesize(LinkService::class)->reveal() + $this->prophesize(LinkService::class)->reveal(), + new TypoLinkCodecService(), + $this->prophesize(SiteFinder::class)->reveal() ); self::assertInstanceOf(AfterLinkIsGeneratedListener::class, $listener); @@ -48,13 +53,17 @@ public function test__invokeNotModifingAnything() $siteFinder = $this->prophesize(SiteFinder::class); $listener = new AfterLinkIsGeneratedListener( + $this->prophesize(Logger::class)->reveal(), new UrlUtility(null, $resolver->reveal(), $siteFinder->reveal()), - $this->prophesize(LinkService::class)->reveal() + $this->prophesize(LinkService::class)->reveal(), + new TypoLinkCodecService(), + $this->prophesize(SiteFinder::class)->reveal() ); $site = new Site('test', 1, []); $cObj = $this->prophesize(ContentObjectRenderer::class); $cObj->getRequest()->willReturn((new ServerRequest())->withAttribute('site', $site)); + $cObj->stdWrapValue(Argument::is('ATagParams'), Argument::is([]))->willReturn(''); $linkResult = new LinkResult('page', '/'); $linkResult = $linkResult->withLinkText('|'); @@ -97,11 +106,15 @@ public function test__invokeModifingFromPageUid() $urlUtility->withRequest($request)->willReturn($urlUtility->reveal()); $listener = new AfterLinkIsGeneratedListener( + $this->prophesize(Logger::class)->reveal(), $urlUtility->reveal(), - $this->prophesize(LinkService::class)->reveal() + $this->prophesize(LinkService::class)->reveal(), + new TypoLinkCodecService(), + $this->prophesize(SiteFinder::class)->reveal() ); $linkResult = new LinkResult('page', '/'); + $linkResult = $linkResult->withLinkConfiguration(['parameter' => 2]); $linkResult = $linkResult->withLinkText('t3://page?uid=2'); $event = new AfterLinkIsGeneratedEvent($linkResult, $cObj->reveal(), []); @@ -110,39 +123,6 @@ public function test__invokeModifingFromPageUid() self::assertSame('https://frontend-domain.tld/page', $event->getLinkResult()->getUrl()); } - public function test__invokeModifingWithoutPageId() - { - $resolver = $this->prophesize(Resolver::class); - $resolver->evaluate(Argument::any())->willReturn(true); - - $site = new Site('test', 1, []); - - $urlUtility = $this->prophesize(UrlUtility::class); - $urlUtility->getFrontendUrlWithSite( - Argument::is('/'), - Argument::is($site), - Argument::is('frontendBase') - )->willReturn('https://front.typo3.tld'); - - $cObj = $this->prophesize(ContentObjectRenderer::class); - $request = (new ServerRequest())->withAttribute('site', $site); - $cObj->getRequest()->willReturn($request); - - $urlUtility->withRequest($request)->willReturn($urlUtility->reveal()); - - $listener = new AfterLinkIsGeneratedListener( - $urlUtility->reveal(), - $this->prophesize(LinkService::class)->reveal() - ); - $linkResult = new LinkResult('page', '/'); - $linkResult = $linkResult->withLinkText('|'); - - $event = new AfterLinkIsGeneratedEvent($linkResult, $cObj->reveal(), []); - $listener($event); - - self::assertSame('https://front.typo3.tld', $event->getLinkResult()->getUrl()); - } - public function test__invokeModifingExternalSite() { $resolver = $this->prophesize(Resolver::class); @@ -162,7 +142,13 @@ public function test__invokeModifingExternalSite() $urlUtility->withRequest($request)->willReturn($urlUtility->reveal()); - $listener = new AfterLinkIsGeneratedListener($urlUtility->reveal(), $linkService->reveal()); + $listener = new AfterLinkIsGeneratedListener( + $this->prophesize(Logger::class)->reveal(), + $urlUtility->reveal(), + $linkService->reveal(), + new TypoLinkCodecService(), + $this->prophesize(SiteFinder::class)->reveal() + ); $linkResult = new LinkResult('page', '/'); $linkResult = $linkResult->withLinkConfiguration(['parameter.' => ['data' => 'parameters:href']]); $linkResult = $linkResult->withLinkText('|'); @@ -195,9 +181,18 @@ public function test__SitemapLink() $request = (new ServerRequest())->withAttribute('site', $site); $cObj->getRequest()->willReturn($request); + $siteFinder = $this->prophesize(SiteFinder::class); + $siteFinder->getSiteByPageId(Argument::any())->willReturn($site); + $urlUtility->withRequest($request)->willReturn($urlUtility->reveal()); - $listener = new AfterLinkIsGeneratedListener($urlUtility->reveal(), $linkService->reveal()); + $listener = new AfterLinkIsGeneratedListener( + $this->prophesize(Logger::class)->reveal(), + $urlUtility->reveal(), + $linkService->reveal(), + new TypoLinkCodecService(), + $siteFinder->reveal() + ); $linkResult = new LinkResult('page', 'https://typo3.tld/sitemap-type/pages/sitemap.xml'); $linkResult = $linkResult->withLinkConfiguration([