Files

354 lines
17 KiB
PHP

<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Frontend\Typolink;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService;
use TYPO3\CMS\Core\Page\DefaultJavaScriptAssetTrait;
use TYPO3\CMS\Core\Resource\Exception\InvalidPathException;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\Event\AfterLinkIsGeneratedEvent;
/**
* Main class for generating any kind of frontend links.
* Contains all logic for the infamous typolink() functionality.
*/
#[Autoconfigure(public: true)]
readonly class LinkFactory
{
use DefaultJavaScriptAssetTrait;
public function __construct(
protected LinkService $linkService,
protected EventDispatcherInterface $eventDispatcher,
protected TypoLinkCodecService $typoLinkCodecService,
#[Autowire(service: 'cache.runtime')]
protected FrontendInterface $runtimeCache,
protected SiteFinder $siteFinder,
protected LoggerInterface $logger,
) {}
/**
* Main method to create links from typolink strings and configuration.
* @throws UnableToLinkException
*/
public function create(string $linkText, array $linkConfiguration, ContentObjectRenderer $contentObjectRenderer): LinkResultInterface
{
if (isset($linkConfiguration['parameter.'])) {
// Evaluate "parameter." stdWrap but keep additional information (like target, class and title)
$linkParameterParts = $this->typoLinkCodecService->decode((string)($linkConfiguration['parameter'] ?? ''));
$modifiedLinkParameterString = $contentObjectRenderer->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, $target, $classList, $title, $rel, $download] = $this->resolveTypolinkParameterString($linkParameter, $linkConfiguration);
} catch (UnableToLinkException $e) {
$this->logger->warning($e->getMessage(), ['linkConfiguration' => $linkConfiguration]);
throw $e;
}
$linkDetails = $this->resolveLinkDetails($linkParameter, $linkConfiguration, $contentObjectRenderer);
if ($linkDetails === null) {
throw new UnableToLinkException('Could not resolve link details from ' . $linkParameter, 1642001442, null, $linkText);
}
$linkResult = $this->buildLinkResult($linkText, $linkDetails, $target, $linkConfiguration, $contentObjectRenderer);
// Enrich the link result with resolved attributes and run post processing
$linkResult = $this->addAdditionalAnchorTagAttributes($linkResult, $linkConfiguration, $contentObjectRenderer);
if ($rel !== '') {
$linkResult = $linkResult->withAttribute('rel', $rel);
}
// Check, if the target is coded as a JS open window link:
$linkResult = $this->addJavaScriptOpenWindowInformationAttributes($linkResult, $linkConfiguration, $contentObjectRenderer);
$linkResult = $this->addSecurityRelValues($linkResult, $contentObjectRenderer);
// Title attribute, will override any title attribute from ->addAdditionalAnchorTagAttributes()
$title = $title ?: trim((string)$contentObjectRenderer->stdWrapValue('title', $linkConfiguration));
if (!empty($title)) {
$linkResult = $linkResult->withAttribute('title', $title);
}
// Class attribute, will override any class attribute from ->addAdditionalAnchorTagAttributes()
if (!empty($classList)) {
$linkResult = $linkResult->withAttribute('class', $classList);
}
// Download attribute
if ($download !== '') {
$linkResult = $linkResult->withAttribute('download', $download === 'true' ? '' : $download);
}
if ($linkConfiguration['userFunc'] ?? false) {
$linkResult = $contentObjectRenderer->callUserFunction($linkConfiguration['userFunc'], $linkConfiguration['userFunc.'] ?? [], $linkResult);
if (!($linkResult instanceof LinkResultInterface)) {
throw new UnableToLinkException('Calling typolink.userFunc resulted in not returning a valid typolink', 1642171035, null, $linkText);
}
}
$event = new AfterLinkIsGeneratedEvent($linkResult, $contentObjectRenderer, $linkConfiguration);
$event = $this->eventDispatcher->dispatch($event);
return $event->getLinkResult();
}
/**
* Creates a link result for a given URL (usually something like "19 _blank css-class "testtitle with whitespace" &X=y").
* Helpful if you want to create any kind of URL (also possible in TYPO3 Backend).
*/
public function createUri(string $urlParameter, ?ContentObjectRenderer $contentObjectRenderer = null): LinkResultInterface
{
if ($contentObjectRenderer === null) {
$contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class);
// @todo: LinkFactory needs the request to determine fallback page uid when link config has none.
$contentObjectRenderer->setRequest($GLOBALS['TYPO3_REQUEST']);
}
return $this->create('', ['parameter' => $urlParameter], $contentObjectRenderer);
}
/**
* Dispatches the linkDetails + configuration to the concrete typolink Builder (page, email etc)
* and returns a LinkResultInterface.
*/
protected function buildLinkResult(string $linkText, array $linkDetails, string $target, array $linkConfiguration, ContentObjectRenderer $contentObjectRenderer): LinkResultInterface
{
if (isset($linkDetails['type'])) {
$builderType = $GLOBALS['TYPO3_CONF_VARS']['FE']['typolinkBuilder'][$linkDetails['type']] ?? null;
} else {
$builderType = null;
}
if ($builderType && is_subclass_of($builderType, TypolinkBuilderInterface::class)) {
/** @var TypolinkBuilderInterface $linkBuilder */
$linkBuilder = GeneralUtility::makeInstance($builderType);
$linkDetails['target'] = $target;
try {
$request = $contentObjectRenderer->getRequest();
$request = $request->withAttribute('currentContentObject', $contentObjectRenderer);
return $linkBuilder->buildLink($linkDetails, $linkConfiguration, $request, $linkText);
} catch (UnableToLinkException $e) {
$this->logger->debug('Unable to link "{text}"', [
'text' => $e->getLinkText(),
'exception' => $e,
]);
// Only return the link text directly
throw $e;
}
} elseif (isset($linkDetails['url'])) {
$linkResult = new LinkResult($linkDetails['type'], $linkDetails['url']);
return $linkResult
->withTarget($target)
->withLinkConfiguration($linkConfiguration)
->withLinkText($linkText);
}
throw new UnableToLinkException('No suitable link handler for resolving ' . $linkDetails['typoLinkParameter'], 1642000232, null, $linkText);
}
/**
* Creates $linkDetails out of the link parameter so the concrete LinkBuilder can be resolved.
*/
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;
}
/**
* Does the magic to split the full "typolink" string like "15,13 _blank myclass &more=1" into separate parts
*
* @param string $mixedLinkParameter destination data like "15,13 _blank myclass &more=1" used to create the link
* @param array $linkConfiguration TypoScript configuration
*/
protected 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('Insecure 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'],
$linkParameterParts['rel'] ?? '',
$linkParameterParts['download'] ?? '',
];
}
protected function addJavaScriptOpenWindowInformationAttributes(LinkResultInterface $linkResult, array $linkConfiguration, ContentObjectRenderer $contentObjectRenderer): LinkResultInterface
{
$JSwindowParts = [];
if ($linkResult->getTarget() && preg_match('/^([0-9]+)x([0-9]+)(:(.*)|.*)$/', $linkResult->getTarget(), $JSwindowParts)) {
// Take all pre-configured and inserted parameters and compile parameter list, including width+height:
$JSwindow_tempParamsArr = GeneralUtility::trimExplode(',', strtolower(($linkConfiguration['JSwindow_params'] ?? '') . ',' . ($JSwindowParts[4] ?? '')), true);
$JSwindow_paramsArr = [];
$target = $linkConfiguration['target'] ?? 'FEopenLink';
foreach ($JSwindow_tempParamsArr as $JSv) {
[$JSp, $JSv] = explode('=', $JSv, 2);
// If the target is set as JS param, this is extracted
if ($JSp === 'target') {
$target = $JSv;
} else {
$JSwindow_paramsArr[$JSp] = $JSp . '=' . $JSv;
}
}
// Add width/height:
$JSwindow_paramsArr['width'] = 'width=' . $JSwindowParts[1];
$JSwindow_paramsArr['height'] = 'height=' . $JSwindowParts[2];
$JSwindowAttrs = [
'data-window-url' => $linkResult->getUrl(),
'data-window-target' => $target,
'data-window-features' => implode(',', $JSwindow_paramsArr),
];
$linkResult = $linkResult->withAttributes($JSwindowAttrs);
$linkResult = $linkResult->withAttribute('target', $target);
$this->addDefaultFrontendJavaScript($contentObjectRenderer->getRequest());
}
return $linkResult;
}
/**
* An abstraction method to add parameters to an A tag.
* Uses the ATagParams property, also includes the global TypoScript config.ATagParams
*/
protected function addAdditionalAnchorTagAttributes(LinkResultInterface $linkResult, array $linkConfiguration, ContentObjectRenderer $contentObjectRenderer): LinkResultInterface
{
$request = $contentObjectRenderer->getRequest();
$frontendTypoScriptConfigArray = $request->getAttribute('frontend.typoscript')?->getConfigArray();
$aTagParams = $contentObjectRenderer->stdWrapValue('ATagParams', $linkConfiguration);
// Add the global config.ATagParams
$globalParams = $frontendTypoScriptConfigArray['ATagParams'] ?? '';
$aTagParams = trim($globalParams . ' ' . $aTagParams);
if (!empty($aTagParams)) {
// Decode entities here, as they are doubly escaped again when using HTML output
$aTagParams = GeneralUtility::get_tag_attributes($aTagParams, true);
// Ensure "href" is not in the list of aTagParams to avoid double tags, usually happens within buggy parseFunc settings
unset($aTagParams['href']);
$linkResult = $linkResult->withAttributes($aTagParams);
}
return $linkResult;
}
protected function addSecurityRelValues(LinkResultInterface $linkResult, ContentObjectRenderer $contentObjectRenderer): LinkResultInterface
{
$target = (string)($linkResult->getTarget() ?: $linkResult->getAttribute('data-window-target'));
if (in_array($target, ['', null, '_self', '_parent', '_top'], true) || $this->isInternalUrl($linkResult->getUrl(), $contentObjectRenderer->getRequest())) {
return $linkResult;
}
// build array of existing rel attribute values
if ($linkResult->getAttribute('rel') !== null) {
$relAttributeArray = GeneralUtility::trimExplode(' ', $linkResult->getAttribute('rel'));
} else {
$relAttributeArray = [];
}
// neither "noopener" nor "noreferrer" exists
if (!array_intersect(['noopener', 'noreferrer'], $relAttributeArray)) {
$typoScriptConfigArray = $contentObjectRenderer->getRequest()->getAttribute('frontend.typoscript')?->getConfigArray();
if (isset($typoScriptConfigArray['linkSecurityRelValue']) && strtolower($typoScriptConfigArray['linkSecurityRelValue']) === 'noopener') {
$relAttributeArray[] = 'noopener';
} else {
$relAttributeArray[] = 'noreferrer';
}
}
return $linkResult->withAttribute('rel', implode(' ', $relAttributeArray));
}
/**
* Checks whether the given url is an internal url.
*
* It will check the host part only, against all configured sites
* whether the given host is any. If so, the url is considered internal.
*
* Note: It would be good to move this to EXT:core/Classes/Site which accepts also a PSR-7 request and
* also accepts a PSR-7 Uri.
*/
protected function isInternalUrl(string $url, ServerRequestInterface $request): bool
{
$parsedUrl = parse_url($url);
$foundDomains = 0;
if (!isset($parsedUrl['host'])) {
return true;
}
$cacheIdentifier = sha1('isInternalDomain' . $parsedUrl['host']);
if ($this->runtimeCache->has($cacheIdentifier) === false) {
foreach ($this->siteFinder->getAllSites() as $site) {
if ($site->getBase()->getHost() === $parsedUrl['host']) {
++$foundDomains;
break;
}
if ($site->getBase()->getHost() === '' && GeneralUtility::isOnCurrentHost($url, $request)) {
++$foundDomains;
break;
}
}
$this->runtimeCache->set($cacheIdentifier, $foundDomains > 0);
}
return (bool)$this->runtimeCache->get($cacheIdentifier);
}
}