TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:27 +02:00
commit 44c503b2d1
233 changed files with 31556 additions and 0 deletions
@@ -0,0 +1,145 @@
<?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\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\Page\FrontendUrlPrefix;
/**
* Abstract class to provide proper helper for most types necessary
*/
abstract class AbstractTypolinkBuilder
{
/**
* Forces a given URL to be absolute.
*
* @param string $url The URL to be forced to be absolute
* @param array $configuration TypoScript configuration of typolink
* @return string The absolute URL
*/
protected function forceAbsoluteUrl(string $url, array $configuration, ?ServerRequestInterface $request = null): string
{
$frontendTypoScriptConfigArray = $request ? $request->getAttribute('frontend.typoscript')?->getConfigArray() : [];
if ($frontendTypoScriptConfigArray['forceAbsoluteUrls'] ?? false) {
$forceAbsoluteUrl = true;
} else {
$forceAbsoluteUrl = !empty($configuration['forceAbsoluteUrl']);
}
// This part typically touches ONLY files/folders and external URLs, and ONLY the ones that do not have
// "config.forceAbsoluteUrls" but only "typolink.forceAbsoluteUrl" set. Ideally, we could evaluate this
// in the FAL ResourceUriGenerator and then remove this logic. Also see the comment below with the @todo
if (!empty($url) && $forceAbsoluteUrl && preg_match('#^(?:([a-z]+)(://)([^/]*)/?)?(.*)$#', $url, $matches)) {
$urlParts = [
'scheme' => $matches[1],
'delimiter' => '://',
'host' => $matches[3],
'path' => $matches[4],
];
$isUrlModified = false;
// Set scheme and host if not yet part of the URL
if (empty($urlParts['host'])) {
// absRefPrefix has been prepended to $url beforehand
// so we only modify the path if no absRefPrefix has been set
// otherwise we would destroy the path
$normalizedParams = $request->getAttribute('normalizedParams');
// @todo: This fallback should vanish mid-term: typolink has a dependency to ServerRequest
// and should expect the normalizedParams argument is properly set as well. When for
// instance CLI triggers this code, it should have set up a proper request.
$normalizedParams ??= NormalizedParams::createFromRequest($request);
$urlParts['scheme'] = $normalizedParams->isHttps() ? 'https' : 'http';
$urlParts['host'] = $normalizedParams->getHttpHost();
if (GeneralUtility::makeInstance(FrontendUrlPrefix::class)->getUrlPrefix($request) === '') {
// Remove any possible leading slashes
$urlParts['path'] = ltrim($urlParts['path'], '/');
// Ensure that sitePath will have a "/" between path and sitePath
if (str_starts_with($normalizedParams->getSitePath(), '/')) {
$urlParts['path'] = '/' . $urlParts['path'];
}
$urlParts['path'] = rtrim($normalizedParams->getSitePath(), '/') . $urlParts['path'];
}
$isUrlModified = true;
}
// Override scheme:
$forcedScheme = $configuration['forceAbsoluteUrl.']['scheme'] ?? null;
if (!empty($forcedScheme) && $urlParts['scheme'] !== $forcedScheme) {
$urlParts['scheme'] = $forcedScheme;
$isUrlModified = true;
}
// Also ensure the path has a "/" at the beginning when concatenating everything else together
if ($urlParts['path'] !== '') {
$urlParts['path'] = '/' . ltrim($urlParts['path'], '/');
$isUrlModified = true;
}
// Recreate the absolute URL:
if ($isUrlModified) {
$url = implode('', $urlParts);
}
}
return $url;
}
/**
* Helper method to a fallback method properly encoding HTML.
*
* @param string $originalLinkText the original string, if empty, the fallback link text
* @param string $fallbackLinkText the string to be used.
* @return string the final text
*/
protected function encodeFallbackLinkTextIfLinkTextIsEmpty(string $originalLinkText, string $fallbackLinkText): string
{
if ($originalLinkText !== '') {
return $originalLinkText;
}
return htmlspecialchars($fallbackLinkText, ENT_QUOTES);
}
/**
* Creates the value for target="..." in a typolink configuration
*
* @param array $conf the typolink configuration
* @param string $name the key, usually "target", "extTarget" or "fileTarget"
* @return string the value of the target attribute, if there is one
*/
protected function resolveTargetAttribute(array $conf, string $name, ?ContentObjectRenderer $contentObjectRenderer = null): string
{
$target = '';
if (isset($conf[$name]) && $conf[$name] !== '') {
$target = $conf[$name];
} elseif (!($conf['directImageLink'] ?? false)) {
$frontendTypoScriptConfigArray = $contentObjectRenderer ? $contentObjectRenderer->getRequest()->getAttribute('frontend.typoscript')?->getConfigArray() : [];
switch ($name) {
case 'extTarget':
case 'fileTarget':
$target = (string)($frontendTypoScriptConfigArray[$name] ?? '');
break;
case 'target':
$target = (string)($frontendTypoScriptConfigArray['intTarget'] ?? '');
break;
}
}
if (isset($conf[$name . '.']) && $conf[$name . '.']) {
if ($contentObjectRenderer) {
$target = (string)$contentObjectRenderer->stdWrap($target, $conf[$name . '.'] ?? []);
}
}
return $target;
}
}
@@ -0,0 +1,168 @@
<?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 Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Http\ApplicationType;
use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\Entity\NullSite;
use TYPO3\CMS\Core\TypoScript\PageTsConfig;
use TYPO3\CMS\Core\TypoScript\PageTsConfigFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\Event\BeforeDatabaseRecordLinkResolvedEvent;
/**
* Builds a TypoLink to a database record
*/
readonly class DatabaseRecordLinkBuilder implements TypolinkBuilderInterface
{
public function __construct(
private TcaSchemaFactory $schemaFactory,
#[Autowire(service: 'cache.runtime')]
private FrontendInterface $runtimeCache,
private TypoLinkCodecService $typoLinkCodecService,
private EventDispatcherInterface $eventDispatcher,
private PageRepository $pageRepository,
) {}
public function buildLink(
array $linkDetails,
array $configuration,
ServerRequestInterface $request,
string $linkText = '',
): LinkResultInterface {
$pageTsConfig = $this->getPageTsConfig($request);
$configurationKey = $linkDetails['identifier'] . '.';
$typoScriptArray = $request->getAttribute('frontend.typoscript')?->getSetupArray() ?? [];
$typoScriptLinkHandlerConfiguration = $typoScriptArray['config.']['recordLinks.'] ?? [];
$linkHandlerConfiguration = $pageTsConfig['TCEMAIN.']['linkHandler.'] ?? [];
if (!isset($typoScriptLinkHandlerConfiguration[$configurationKey], $linkHandlerConfiguration[$configurationKey])) {
throw new UnableToLinkException(
'Configuration how to link "' . $linkDetails['typoLinkParameter'] . '" was not found, so "' . $linkText . '" was not linked.',
1490989149,
null,
$linkText
);
}
$typoScriptConfiguration = $typoScriptLinkHandlerConfiguration[$configurationKey]['typolink.'];
$linkHandlerConfiguration = $linkHandlerConfiguration[$configurationKey]['configuration.'];
$databaseTable = (string)($linkHandlerConfiguration['table'] ?? '');
$event = $this->eventDispatcher->dispatch(
new BeforeDatabaseRecordLinkResolvedEvent(
$linkDetails,
$databaseTable,
$typoScriptLinkHandlerConfiguration,
$linkHandlerConfiguration,
$request
)
);
$record = $event->record;
if ($record === null) {
if ($typoScriptLinkHandlerConfiguration[$configurationKey]['forceLink'] ?? false) {
$record = $this->pageRepository->getRawRecord($databaseTable, (int)$linkDetails['uid']);
} else {
$record = $this->pageRepository->checkRecord($databaseTable, (int)$linkDetails['uid']);
$languageAspect = GeneralUtility::makeInstance(Context::class)->getAspect('language');
if (is_array($record) && $this->schemaFactory->has($databaseTable)) {
$schema = $this->schemaFactory->get($databaseTable);
if ($schema->isLanguageAware()) {
$languageField = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField(
)->getName();
$languageIdOfRecord = $record[$languageField];
// If a record is already in a localized version OR if the record is set to "All Languages"
// we allow the generation of the link
if ($languageIdOfRecord === 0 && $languageAspect->doOverlays()) {
$overlay = $this->pageRepository->getLanguageOverlay(
$databaseTable,
$record,
$languageAspect
);
// If the record is not translated (overlays enabled), even though it should have been done
// We avoid linking to it
if (!isset($overlay['_LOCALIZED_UID'])) {
$record = null;
}
}
}
}
}
}
if ($record === null) {
throw new UnableToLinkException(
'Record not found for "' . $linkDetails['typoLinkParameter'] . '" was not found, so "' . $linkText . '" was not linked.',
1490989659,
null,
$linkText
);
}
// Unset the parameter part of the given TypoScript configuration while keeping
// config that has been set in addition.
unset($configuration['parameter.']);
$parameterFromDb = $this->typoLinkCodecService->decode((string)($configuration['parameter'] ?? ''));
unset($parameterFromDb['url']);
$parameterFromTypoScript = $this->typoLinkCodecService->decode((string)($typoScriptConfiguration['parameter'] ?? ''));
$parameter = array_replace_recursive($parameterFromTypoScript, array_filter($parameterFromDb));
$typoScriptConfiguration['parameter'] = $this->typoLinkCodecService->encode($parameter);
$typoScriptConfiguration = array_replace_recursive($configuration, $typoScriptConfiguration);
if (!empty($linkDetails['fragment'])) {
$typoScriptConfiguration['section'] = $linkDetails['fragment'];
}
// Build the full link to the record by calling LinkFactory again ("inception")
$localContentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$localContentObjectRenderer->setRequest($request);
$localContentObjectRenderer->start($record, $databaseTable);
$localContentObjectRenderer->parameters = $request->getAttribute('currentContentObject')->parameters ?? [];
return $localContentObjectRenderer->createLink($linkText, $typoScriptConfiguration);
}
/**
* Helper method to calculate pageTsConfig in frontend scope, we can't use BackendUtility::getPagesTSconfig() here.
*/
protected function getPageTsConfig(ServerRequestInterface $request): array
{
if (!ApplicationType::fromRequest($request)->isFrontend()) {
return [];
}
$pageInformation = $request->getAttribute('frontend.page.information');
$id = $pageInformation->getId();
$fullRootLine = $pageInformation->getRootLine();
$pageTsConfig = $this->runtimeCache->get('pageTsConfig-' . $id);
if ($pageTsConfig instanceof PageTsConfig) {
return $pageTsConfig->getPageTsConfigArray();
}
ksort($fullRootLine);
$site = $request->getAttribute('site') ?? new NullSite();
$pageTsConfig = GeneralUtility::makeInstance(PageTsConfigFactory::class)->create($fullRootLine, $site);
$this->runtimeCache->set('pageTsConfig-' . $id, $pageTsConfig);
return $pageTsConfig->getPageTsConfigArray();
}
}
+163
View File
@@ -0,0 +1,163 @@
<?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\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\Page\DefaultJavaScriptAssetTrait;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Builds a TypoLink to an email address, also takes care of additional functionality for the time being
* such as the infamous config.spamProtectedEmailAddresses option.
*/
class EmailLinkBuilder implements LoggerAwareInterface, TypolinkBuilderInterface
{
use DefaultJavaScriptAssetTrait;
use LoggerAwareTrait;
public function __construct(
private readonly LinkService $linkService,
) {}
public function buildLink(
array $linkDetails,
array $configuration,
ServerRequestInterface $request,
string $linkText = '',
): LinkResultInterface {
[$url, $linkText, $attributes] = $this->processEmailLink($linkDetails['email'], $linkText, $linkDetails, $request);
return (new LinkResult(LinkService::TYPE_EMAIL, $url))
->withTarget($linkDetails['target'] ?? '')
->withLinkConfiguration($configuration)
->withLinkText($linkText)
->withAttributes($attributes);
}
/**
* Creates a href attribute for given $mailAddress.
* The function uses spamProtectEmailAddresses for encoding the mailto statement.
* If spamProtectEmailAddresses is disabled, it'll just return a string like "mailto:user@example.tld".
*
* Returns an array with three items (numeric index)
* #0: $mailToUrl (string), ready to be inserted into the href attribute of the <a> tag
* #1: $linkText (string), content between starting and ending `<a>` tag
* #2: $attributes (array<string, string>), additional attributes for `<a>` tag
*
* @param string $mailAddress Email address
* @param string $linkText Link text, default will be the email address.
* @return array{0: string, 1: string, 2: array<string, string>} A numerical array with three items
* @internal this method is not part of TYPO3's public API
*/
public function processEmailLink(string $mailAddress, string $linkText, array $linkDetails, ServerRequestInterface $request): array
{
$linkText = $linkText ?: htmlspecialchars($mailAddress);
$attributes = [];
if ($linkDetails !== []) {
// Ensure to add also additional query parameters to the string
$mailToUrl = $this->linkService->asString($linkDetails);
} else {
$mailToUrl = 'mailto:' . $mailAddress;
}
// no processing happened, therefore, the default processing kicks in
$frontendTypoScriptConfigArray = $request->getAttribute('frontend.typoscript')?->getConfigArray();
$spamProtectEmailAddresses = (int)($frontendTypoScriptConfigArray['spamProtectEmailAddresses'] ?? 0);
$spamProtectEmailAddresses = MathUtility::forceIntegerInRange($spamProtectEmailAddresses, -10, 10);
if ($spamProtectEmailAddresses !== 0) {
$mailToUrl = $this->encryptEmail($mailToUrl, $spamProtectEmailAddresses);
$attributes = [
'data-mailto-token' => $mailToUrl,
'data-mailto-vector' => $spamProtectEmailAddresses,
];
$mailToUrl = '#';
$this->addDefaultFrontendJavaScript($request);
$atLabel = '(at)';
if (($atLabelFromConfig = trim($frontendTypoScriptConfigArray['spamProtectEmailAddresses_atSubst'] ?? '')) !== '') {
$atLabel = $atLabelFromConfig;
}
$spamProtectedMailAddress = str_replace('@', $atLabel, htmlspecialchars($mailAddress));
if ($frontendTypoScriptConfigArray['spamProtectEmailAddresses_lastDotSubst'] ?? false) {
$lastDotLabel = trim($frontendTypoScriptConfigArray['spamProtectEmailAddresses_lastDotSubst']);
$lastDotLabel = $lastDotLabel ?: '(dot)';
$spamProtectedMailAddress = preg_replace('/\\.([^\\.]+)$/', $lastDotLabel . '$1', $spamProtectedMailAddress);
if ($spamProtectedMailAddress === null) {
$this->logger->debug('Error replacing the last dot in email address "{email}"', ['email' => $spamProtectedMailAddress]);
$spamProtectedMailAddress = '';
}
}
$linkText = str_ireplace(htmlspecialchars($mailAddress), $spamProtectedMailAddress, $linkText);
}
return [$mailToUrl, $linkText, $attributes];
}
/**
* Encryption of email addresses for <A>-tags See the spam protection setup in TS 'config.'
*
* @param string $string Input string to en/decode: "mailto:some@example.com
* @param int $offset a number between -10 and 10, taken from config.spamProtectEmailAddresses
* @return string encoded version of $string
*/
protected function encryptEmail(string $string, int $offset): string
{
$out = '';
// like str_rot13() but with a variable offset and a wider character range
$len = strlen($string);
for ($i = 0; $i < $len; $i++) {
$charValue = ord($string[$i]);
// 0-9 . , - + / :
if ($charValue >= 43 && $charValue <= 58) {
$out .= $this->encryptCharcode($charValue, 43, 58, $offset);
} elseif ($charValue >= 64 && $charValue <= 90) {
// A-Z @
$out .= $this->encryptCharcode($charValue, 64, 90, $offset);
} elseif ($charValue >= 97 && $charValue <= 122) {
// a-z
$out .= $this->encryptCharcode($charValue, 97, 122, $offset);
} else {
$out .= $string[$i];
}
}
return $out;
}
/**
* Encryption (or decryption) of a single character.
* Within the given range the character is shifted with the supplied offset.
*
* @param int $n Ordinal of input character
* @param int $start Start of range
* @param int $end End of range
* @param int $offset Offset
* @return string encoded/decoded version of character
*/
protected function encryptCharcode(int $n, int $start, int $end, int $offset): string
{
$n = $n + $offset;
if ($offset > 0 && $n > $end) {
$n = $start + ($n - $end - 1);
} elseif ($offset < 0 && $n < $start) {
$n = $end - ($start - $n - 1);
}
return chr($n);
}
}
@@ -0,0 +1,50 @@
<?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\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\LinkHandling\LinkService;
/**
* Builds a TypoLink to an external URL
*/
class ExternalUrlLinkBuilder extends AbstractTypolinkBuilder implements TypolinkBuilderInterface
{
public function buildLink(
array $linkDetails,
array $configuration,
ServerRequestInterface $request,
string $linkText = '',
): LinkResultInterface {
$url = $linkDetails['url'] ?? '';
$target = $linkDetails['target'] ?? '';
// issue https://forge.typo3.org/issues/101083 forces absolute path URLs
// like `/path/some-file.png` to be handled as external URL, and that's
// why the URL is forced to contain a fully qualified domain name as well
$url = $this->forceAbsoluteUrl($url, $configuration, $request);
$fallbackTarget = str_starts_with($url, '/') && !str_starts_with($url, '//') ? 'target' : 'extTarget';
$linkText = $this->encodeFallbackLinkTextIfLinkTextIsEmpty($linkText, $url);
return (new LinkResult(LinkService::TYPE_URL, (string)$url))
->withLinkConfiguration($configuration)
->withTarget(
$target ?: $this->resolveTargetAttribute($configuration, $fallbackTarget, $request->getAttribute('currentContentObject')),
)
->withLinkText($linkText);
}
}
@@ -0,0 +1,63 @@
<?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\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Resource\FileInterface;
use TYPO3\CMS\Core\Resource\Folder;
/**
* Builds a TypoLink to a folder or file
*/
class FileOrFolderLinkBuilder extends AbstractTypolinkBuilder implements TypolinkBuilderInterface
{
public function buildLink(
array $linkDetails,
array $configuration,
ServerRequestInterface $request,
string $linkText = '',
): LinkResultInterface {
$target = $linkDetails['target'] ?? '';
$fileOrFolderObject = ($linkDetails['file'] ?? false) ?: ($linkDetails['folder'] ?? null);
// check if the file exists or if a / is contained (same check as in detectLinkType)
if (!($fileOrFolderObject instanceof FileInterface) && !($fileOrFolderObject instanceof Folder)) {
throw new UnableToLinkException(
'File "' . $linkDetails['typoLinkParameter'] . '" did not exist, so "' . $linkText . '" was not linked.',
1490989449,
null,
$linkText
);
}
$linkLocation = $fileOrFolderObject->getPublicUrl();
if ($linkLocation === null) {
// set the linkLocation to an empty string if null,
// so it does not collide with the various string functions
$linkLocation = '';
}
// Setting title if blank value to link
$linkText = $this->encodeFallbackLinkTextIfLinkTextIsEmpty($linkText, rawurldecode($linkLocation));
$url = $linkLocation;
if (!empty($linkDetails['fragment'])) {
$url .= '#' . $linkDetails['fragment'];
}
return (new LinkResult($linkDetails['type'], $this->forceAbsoluteUrl($url, $configuration, $request)))
->withLinkConfiguration($configuration)
->withTarget($target ?: $this->resolveTargetAttribute($configuration, 'fileTarget', $request->getAttribute('currentContentObject')))
->withLinkText($linkText);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?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\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Page\FrontendUrlPrefix;
/**
* Builds a TypoLink to a file (relative to fileadmin/ or something)
* or otherwise detects as an external URL
*/
class LegacyLinkBuilder extends AbstractTypolinkBuilder implements TypolinkBuilderInterface
{
public function buildLink(array $linkDetails, array $configuration, ServerRequestInterface $request, string $linkText = ''): LinkResultInterface
{
$target = $linkDetails['target'] ?? '';
if ($linkDetails['file'] ?? false) {
$linkDetails['type'] = LinkService::TYPE_FILE;
$linkLocation = $linkDetails['file'];
// Setting title if blank value to link
$linkText = $this->encodeFallbackLinkTextIfLinkTextIsEmpty($linkText, rawurldecode($linkLocation));
$absRefPrefix = GeneralUtility::makeInstance(FrontendUrlPrefix::class)->getUrlPrefix($request);
$linkLocation = (!str_starts_with($linkLocation, '/') ? $absRefPrefix : '') . $linkLocation;
$url = $linkLocation;
$url = $this->forceAbsoluteUrl($url, $configuration, $request);
$target = $target ?: $this->resolveTargetAttribute($configuration, 'fileTarget', $request->getAttribute('currentContentObject'));
} elseif ($linkDetails['url'] ?? false) {
$linkDetails['type'] = LinkService::TYPE_URL;
$target = $target ?: $this->resolveTargetAttribute($configuration, 'extTarget', $request->getAttribute('currentContentObject'));
$linkText = $this->encodeFallbackLinkTextIfLinkTextIsEmpty($linkText, $linkDetails['url']);
$url = $linkDetails['url'];
} else {
throw new UnableToLinkException('Unknown link detected, so ' . $linkText . ' was not linked.', 1490990031, null, $linkText);
}
return (new LinkResult((string)$linkDetails['type'], (string)$url))->withTarget($target)->withLinkConfiguration($configuration)->withLinkText($linkText);
}
}
+353
View File
@@ -0,0 +1,353 @@
<?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);
}
}
+261
View File
@@ -0,0 +1,261 @@
<?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 TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This class represents a created link to a resource (page, email etc.), coming from LinkService.
* After it was executed by the LinkBuilders (mostly in Frontend) after it is called from Typolink.
*/
class LinkResult implements LinkResultInterface, \Stringable, \JsonSerializable
{
public const STRING_CAST_HTML = 1;
public const STRING_CAST_JSON = 2;
protected string $type = LinkService::TYPE_UNKNOWN;
protected string $url;
protected string $target = '';
protected array $additionalAttributes = [];
protected ?string $linkText = null;
protected array $linkConfiguration = [];
protected int $flags = self::STRING_CAST_HTML;
/**
* Use this method to create a new LinkResult for a specific output format (HTML or JSON)
*/
public static function adapt(LinkResultInterface $other, int $flags = self::STRING_CAST_HTML): self
{
$target = $other;
if (!$target instanceof self) {
$target = GeneralUtility::makeInstance(self::class, $other->getType(), $other->getUrl());
$target->target = $other->getTarget();
$target->additionalAttributes = $target->filterAdditionalAttributes($other->getAttributes());
$target->linkText = $other->getLinkText();
$target->linkConfiguration = $other->getLinkConfiguration();
}
return $target->withFlags($flags);
}
public function __construct(string $type, string $url)
{
$this->type = $type;
$this->url = $url;
}
public function getUrl(): string
{
return $this->url;
}
public function getType(): string
{
return $this->type;
}
public function getTarget(): string
{
return $this->target;
}
public function withTarget(string $target): self
{
$newObject = clone $this;
$newObject->target = $target;
return $newObject;
}
/**
* @return array<string, string>
*/
public function getLinkConfiguration(): array
{
return $this->linkConfiguration;
}
public function withLinkConfiguration(array $configuration): self
{
$newObject = clone $this;
$newObject->linkConfiguration = $configuration;
return $newObject;
}
public function withLinkText(string $linkText): self
{
$newObject = clone $this;
$newObject->linkText = $linkText;
return $newObject;
}
public function getLinkText(): ?string
{
return $this->linkText;
}
public function withAttributes(array $additionalAttributes, bool $resetExistingAttributes = false): self
{
$newObject = clone $this;
if ($resetExistingAttributes) {
$newObject->additionalAttributes = [];
$newObject->url = '';
$newObject->target = '';
}
foreach ($additionalAttributes as $attributeName => $attributeValue) {
switch ($attributeName) {
case 'href':
$newObject->url = $attributeValue;
break;
case 'target':
$newObject->target = $attributeValue;
break;
}
if ($attributeValue !== null) {
$newObject->additionalAttributes[$attributeName] = $attributeValue;
} else {
unset($newObject->additionalAttributes[$attributeName]);
}
}
return $newObject;
}
public function withAttribute(string $attributeName, ?string $attributeValue): self
{
$newObject = clone $this;
switch ($attributeName) {
case 'href':
$newObject->url = $attributeValue ?? '';
break;
case 'target':
$newObject->target = $attributeValue ?? '';
break;
default:
if ($attributeValue !== null) {
$newObject->additionalAttributes[$attributeName] = $attributeValue;
} else {
unset($newObject->additionalAttributes[$attributeName]);
}
}
return $newObject;
}
public function hasAttribute(string $attributeName): bool
{
switch ($attributeName) {
case 'href':
return $this->url !== '';
case 'target':
return $this->target !== '';
default:
return isset($this->additionalAttributes[$attributeName]);
}
}
public function getAttribute(string $attributeName): ?string
{
switch ($attributeName) {
case 'href':
return $this->url;
case 'target':
return $this->target;
default:
return $this->additionalAttributes[$attributeName] ?? null;
}
}
public function getAttributes(): array
{
$attributes = [];
if ($this->url) {
$attributes['href'] = $this->url;
}
if ($this->target) {
$attributes['target'] = $this->target;
}
return array_merge($attributes, $this->additionalAttributes);
}
public function withFlags(int $flags): self
{
if ($flags !== self::STRING_CAST_HTML && $flags !== self::STRING_CAST_JSON) {
$flags = self::STRING_CAST_HTML;
}
if ($this->flags === $flags) {
return $this;
}
$target = clone $this;
$target->flags = $flags;
return $target;
}
protected function filterAdditionalAttributes(array $attributes): array
{
return array_filter(
$attributes,
static fn(string $key) => !in_array($key, ['href', 'target', 'class', 'title'], true),
ARRAY_FILTER_USE_KEY
);
}
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* @return array{href: ?string, target: ?string, class: ?string, title: ?string, linkText: ?string, additionalAttributes: array}
*/
public function toArray(): array
{
return [
'href' => $this->url ?: null,
'target' => $this->target ?: null,
'class' => $this->getAttribute('class') ?: null,
'title' => $this->getAttribute('title') ?: null,
'linkText' => $this->linkText ?: null,
'additionalAttributes' => $this->filterAdditionalAttributes($this->getAttributes()),
];
}
public function getHtml(): string
{
return sprintf(
'<a %s>%s</a>',
GeneralUtility::implodeAttributes($this->getAttributes(), true, true),
$this->linkText
);
}
public function getJson(): string
{
try {
return json_encode($this, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return '';
}
}
public function __toString(): string
{
if ($this->flags === self::STRING_CAST_HTML) {
return $this->getHtml();
}
if ($this->flags === self::STRING_CAST_JSON) {
return $this->getJson();
}
throw new \LogicException('Unsupported flags assignment', 1666024513);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?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;
/**
* Interface representing a created link to any type (page, file etc).
*/
interface LinkResultInterface
{
public function getUrl(): string;
public function getType(): string;
public function getTarget(): string;
public function getLinkConfiguration(): array;
public function getLinkText(): ?string;
public function withLinkText(string $linkText): self;
public function withTarget(string $target): self;
public function withAttributes(array $additionalAttributes, bool $resetExistingAttributes = false): self;
public function withAttribute(string $attributeName, ?string $attributeValue): self;
public function hasAttribute(string $attributeName): bool;
public function getAttribute(string $attributeName): ?string;
public function getAttributes(): array;
}
+171
View File
@@ -0,0 +1,171 @@
<?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 TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\HttpUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Class to calculate so-called "linkVars", which is a TypoScript setting
* to always append these query parameters (if available in an existing request)
* to a URL when using TypoLink for pages.
*/
readonly class LinkVarsCalculator
{
/**
* Calculates and sets the internal linkVars based upon the current request's GET parameters
* and the setting "config.linkVars".
*/
public function getAllowedLinkVarsFromRequest(string $linkVarsSetting, array $queryParams, Context $context): string
{
$calculatedLinkVars = '';
$adminCommand = $queryParams['ADMCMD_prev'] ?? '';
$isBackendUserLoggedIn = $context->getAspect('backend.user')->isLoggedIn();
// This allows to keep the current logged-in workspace when navigating through the Frontend from a Backend link, and keep the logged-in state
if (($adminCommand === 'LIVE' || $adminCommand === 'IGNORE') && $isBackendUserLoggedIn) {
$linkVarsSetting = ltrim($linkVarsSetting . ',ADMCMD_prev', ',');
}
// This allows to keep the "ADMCMD_simUser" parameter when navigating through the Frontend from a Backend link, and keep the logged-in state
if (!empty($queryParams['ADMCMD_simUser']) && $isBackendUserLoggedIn) {
$linkVarsSetting = ltrim($linkVarsSetting . ',ADMCMD_simUser', ',');
}
// This allows to keep the "ADMCMD_simUser" parameter when navigating through the Frontend from a Backend link, and keep the logged-in state
if (!empty($queryParams['ADMCMD_simTime']) && $isBackendUserLoggedIn) {
$linkVarsSetting = ltrim($linkVarsSetting . ',ADMCMD_simTime', ',');
}
if (empty($linkVarsSetting)) {
return '';
}
$linkVars = $this->splitLinkVarsString($linkVarsSetting);
if (empty($linkVars)) {
return '';
}
foreach ($linkVars as $linkVar) {
$test = '';
if (preg_match('/^(.*)\\((.+)\\)$/', $linkVar, $match)) {
$linkVar = trim($match[1]);
$test = trim($match[2]);
}
$keys = explode('|', $linkVar);
$numberOfLevels = count($keys);
$rootKey = trim($keys[0]);
if (!isset($queryParams[$rootKey])) {
continue;
}
$value = $queryParams[$rootKey];
for ($i = 1; $i < $numberOfLevels; $i++) {
$currentKey = trim($keys[$i]);
if (isset($value[$currentKey])) {
$value = $value[$currentKey];
} else {
$value = false;
break;
}
}
if ($value !== false) {
$parameterName = $keys[0];
for ($i = 1; $i < $numberOfLevels; $i++) {
$parameterName .= '[' . $keys[$i] . ']';
}
if (!is_array($value)) {
$temp = rawurlencode((string)$value);
if ($test !== '' && !$this->isAllowedLinkVarValue($temp, $test)) {
// Error: This value was not allowed for this key
continue;
}
$value = '&' . $parameterName . '=' . $temp;
} else {
if ($test !== '' && $test !== 'array') {
// Error: This key must not be an array!
continue;
}
$value = HttpUtility::buildQueryString([$parameterName => $value], '&');
}
$calculatedLinkVars .= $value;
}
}
return $calculatedLinkVars;
}
/**
* Split the link vars string by "," but not if the "," is inside of braces
*/
protected function splitLinkVarsString(string $string): array
{
$tempCommaReplacementString = '###KASPER###';
// replace every "," wrapped in "()" by a "unique" string
$string = preg_replace_callback('/\((?>[^()]|(?R))*\)/', static function ($result) use ($tempCommaReplacementString) {
return str_replace(',', $tempCommaReplacementString, $result[0]);
}, $string) ?? '';
$string = GeneralUtility::trimExplode(',', $string);
// replace all "unique" strings back to ","
return str_replace($tempCommaReplacementString, ',', $string);
}
/**
* Checks if the value defined in "config.linkVars" contains an allowed value.
* Otherwise, return FALSE which means the value will not be added to any links.
*
* @param string $haystack The string in which to find $value
* @param string $value The string to find in $haystack
* @return bool Returns TRUE if $value matches or is found in $haystack
*/
protected function isAllowedLinkVarValue(string $haystack, string $value): bool
{
$isAllowed = false;
// Integer
if ($value === 'int' || $value === 'integer') {
if (MathUtility::canBeInterpretedAsInteger($haystack)) {
$isAllowed = true;
}
} elseif (preg_match('/^\\/.+\\/[imsxeADSUXu]*$/', $value)) {
// Regular expression, only "//" is allowed as delimiter
if (@preg_match($value, $haystack)) {
$isAllowed = true;
}
} elseif (str_contains($value, '-')) {
// Range
if (MathUtility::canBeInterpretedAsInteger($haystack)) {
$range = explode('-', $value);
if ($range[0] <= $haystack && $range[1] >= $haystack) {
$isAllowed = true;
}
}
} elseif (str_contains($value, '|')) {
// List
// Trim the input
$haystack = str_replace(' ', '', $haystack);
if (str_contains('|' . $value . '|', '|' . $haystack . '|')) {
$isAllowed = true;
}
} elseif ($value === $haystack) {
// String comparison
$isAllowed = true;
}
return $isAllowed;
}
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
<?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\Http\Message\ServerRequestInterface;
/**
* Builds a TypoLink to a telephone number
*/
class TelephoneLinkBuilder implements TypolinkBuilderInterface
{
public function buildLink(array $linkDetails, array $configuration, ServerRequestInterface $request, string $linkText = ''): LinkResultInterface
{
$linkText = $linkText ?: $linkDetails['telephone'] ?? '';
return (new LinkResult($linkDetails['type'], $linkDetails['typoLinkParameter']))->withLinkConfiguration($configuration)->withLinkText($linkText);
}
}
@@ -0,0 +1,36 @@
<?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\Http\Message\ServerRequestInterface;
/**
* Used to generate a link to a page or file, an external URL or any other protocol
* in the frontend or backend.
* The actual resolving of the Link happens in LinkFactory
*/
interface TypolinkBuilderInterface
{
/**
* @param array $linkDetails parsed link details by the LinkService
* @param array $configuration the TypoLink configuration array
* @param string $linkText the link text
* @throws UnableToLinkException
*/
public function buildLink(array $linkDetails, array $configuration, ServerRequestInterface $request, string $linkText = ''): LinkResultInterface;
}
@@ -0,0 +1,39 @@
<?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 TYPO3\CMS\Frontend\Exception;
/**
* Exception which is thrown when a link could not be set
*/
class UnableToLinkException extends Exception
{
public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null, protected string $linkText = '')
{
parent::__construct($message, $code, $previous);
}
/**
* Returns the link text when the link could not been set
*/
public function getLinkText(): string
{
return $this->linkText;
}
}