TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:09 +02:00
commit af8cc155b5
6818 changed files with 642608 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
<?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\Core\Html;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Resource\Security\SvgSanitizer;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
use TYPO3\HtmlSanitizer\Behavior;
use TYPO3\HtmlSanitizer\Behavior\NodeInterface;
use TYPO3\HtmlSanitizer\Builder\CommonBuilder;
use TYPO3\HtmlSanitizer\Context;
use TYPO3\HtmlSanitizer\Sanitizer;
use TYPO3\HtmlSanitizer\Visitor\CommonVisitor;
/**
* Builder, creating a `Sanitizer` instance with "default"
* behavior for tags, attributes and values.
*
* @internal
*/
class DefaultSanitizerBuilder extends CommonBuilder implements SingletonInterface
{
private Behavior $behavior;
public function __construct()
{
parent::__construct();
// + URL must be on local host, or is absolute URI path
$isOnCurrentHostAttr = new Behavior\ClosureAttrValue(
// @todo: This closure has a late dependency to $GLOBALS['TYPO3_REQUEST'] that should eventually
// be made explicit, probably by handing over ServerRequestInterface to build().
static function (string $value): bool {
/** @var ServerRequestInterface|null $request */
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request === null) {
throw new \RuntimeException('DefaultSanitizerBuilder requires an active PSR-7 request with normalizedParams attribute.', 1775675289);
}
return GeneralUtility::isValidUrl($value) && GeneralUtility::isOnCurrentHost($value, $request)
|| PathUtility::isAbsolutePath($value) && GeneralUtility::isAllowedAbsPath($value);
}
);
// + starting with `t3://`
$isTypo3Uri = new Behavior\RegExpAttrValue('#^t3://#');
// extends common attributes for TYPO3-specific URIs
$this->srcAttr->addValues($isOnCurrentHostAttr);
$this->hrefAttr->addValues($isOnCurrentHostAttr, $isTypo3Uri);
// @todo `style` used in Introduction Package, inline CSS should be removed
$this->globalAttrs[] = new Behavior\Attr('style');
}
public function build(): Sanitizer
{
$behavior = $this->createBehavior();
$visitor = GeneralUtility::makeInstance(CommonVisitor::class, $behavior);
return GeneralUtility::makeInstance(Sanitizer::class, $behavior, $visitor);
}
protected function createBehavior(): Behavior
{
if (!isset($this->behavior)) {
$this->behavior = parent::createBehavior()
->withName('default')
->withNodes(new Behavior\NodeHandler(
new Behavior\Tag('svg'),
new Behavior\Handler\ClosureHandler(
static function (NodeInterface $node, ?\DOMNode $domNode, Context $context): ?\DOMNode {
if ($domNode === null) {
return null;
}
$newNode = GeneralUtility::makeInstance(SvgSanitizer::class)
->sanitizeNode($domNode);
// purge empty svg nodes
if ($newNode->childNodes->length === 0) {
return null;
}
$fragment = $domNode->ownerDocument->createDocumentFragment();
$fragment->append($newNode);
return $fragment;
}
)
));
}
return $this->behavior;
}
}
@@ -0,0 +1,51 @@
<?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\Core\Html\Event;
/**
* Event that is fired after RteHtmlParser modified the HTML input from RTE editor to the database
* (for example transforming linebreaks)
*/
final class AfterTransformTextForPersistenceEvent
{
public function __construct(
private string $htmlContent,
private readonly string $initialHtmlContent,
private readonly array $processingConfiguration
) {}
public function getHtmlContent(): string
{
return $this->htmlContent;
}
public function setHtmlContent(string $htmlContent): void
{
$this->htmlContent = $htmlContent;
}
public function getInitialHtmlContent(): string
{
return $this->initialHtmlContent;
}
public function getProcessingConfiguration(): array
{
return $this->processingConfiguration;
}
}
@@ -0,0 +1,51 @@
<?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\Core\Html\Event;
/**
* Event that is fired after RteHtmlParser modified the HTML input from the database to the RTE editor
* (for example transforming linebreaks)
*/
final class AfterTransformTextForRichTextEditorEvent
{
public function __construct(
private string $htmlContent,
private readonly string $initialHtmlContent,
private readonly array $processingConfiguration
) {}
public function getHtmlContent(): string
{
return $this->htmlContent;
}
public function setHtmlContent(string $htmlContent): void
{
$this->htmlContent = $htmlContent;
}
public function getInitialHtmlContent(): string
{
return $this->initialHtmlContent;
}
public function getProcessingConfiguration(): array
{
return $this->processingConfiguration;
}
}
@@ -0,0 +1,51 @@
<?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\Core\Html\Event;
/**
* Event that is fired before RteHtmlParser modified the HTML input from RTE editor to the database
* (for example transforming linebreaks)
*/
final class BeforeTransformTextForPersistenceEvent
{
public function __construct(
private string $htmlContent,
private readonly string $initialHtmlContent,
private readonly array $processingConfiguration
) {}
public function getHtmlContent(): string
{
return $this->htmlContent;
}
public function setHtmlContent(string $htmlContent): void
{
$this->htmlContent = $htmlContent;
}
public function getInitialHtmlContent(): string
{
return $this->initialHtmlContent;
}
public function getProcessingConfiguration(): array
{
return $this->processingConfiguration;
}
}
@@ -0,0 +1,51 @@
<?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\Core\Html\Event;
/**
* Event that is fired before RteHtmlParser modified the HTML input from the database to the RTE editor
* (for example transforming linebreaks)
*/
final class BeforeTransformTextForRichTextEditorEvent
{
public function __construct(
private string $htmlContent,
private readonly string $initialHtmlContent,
private readonly array $processingConfiguration
) {}
public function getHtmlContent(): string
{
return $this->htmlContent;
}
public function setHtmlContent(string $htmlContent): void
{
$this->htmlContent = $htmlContent;
}
public function getInitialHtmlContent(): string
{
return $this->initialHtmlContent;
}
public function getProcessingConfiguration(): array
{
return $this->processingConfiguration;
}
}
@@ -0,0 +1,80 @@
<?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\Core\Html\Event;
use Psr\EventDispatcher\StoppableEventInterface;
/**
* Event that is fired to validate if a link is valid or not.
*/
final class BrokenLinkAnalysisEvent implements StoppableEventInterface
{
private bool $isBroken = false;
private bool $linkWasChecked = false;
/**
* Message why a link was broken (used in e.g. RteHtmlParser as info)
*/
private string $reason = '';
public function __construct(private readonly string $linkType, private readonly array $linkData) {}
public function isPropagationStopped(): bool
{
// prevent other listeners from being called if link has been checked
return $this->linkWasChecked;
}
/**
* Returns the link type as string
* @see LinkService types
*/
public function getLinkType(): string
{
return $this->linkType;
}
/**
* Returns resolved LinkService data, depending on the type
*/
public function getLinkData(): array
{
return $this->linkData;
}
public function markAsCheckedLink(): void
{
$this->linkWasChecked = true;
}
public function markAsBrokenLink(string $reason = ''): void
{
$this->isBroken = true;
$this->reason = $reason;
}
public function isBrokenLink(): bool
{
return $this->isBroken;
}
public function getReason(): string
{
return $this->reason;
}
}
+311
View File
@@ -0,0 +1,311 @@
<?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\Core\Html;
use Psr\Log\LoggerInterface;
readonly class HtmlCropper
{
protected const TAGS = 'a|abbr|address|area|article|aside|audio|b|bdi|bdo|blockquote|body|br|button|caption|cite|code|col|colgroup|data|datalist|dd|del|dfn|div|dl|dt|em|embed|fieldset|figcaption|figure|font|footer|form|h1|h2|h3|h4|h5|h6|header|hr|i|iframe|img|input|ins|kbd|keygen|label|legend|li|link|main|map|mark|meter|nav|object|ol|optgroup|option|output|p|param|pre|progress|q|rb|rp|rt|rtc|ruby|s|samp|section|select|small|source|span|strong|sub|sup|table|tbody|td|textarea|tfoot|th|thead|time|tr|track|u|ul|ut|var|video|wbr';
protected const TAGS_REG_EXP = '
(
(?:
<!--.*?--> # a comment
|
<canvas[^>]*>.*?</canvas> # a canvas tag
|
<script[^>]*>.*?</script> # a script tag
|
<noscript[^>]*>.*?</noscript> # a noscript tag
|
<template[^>]*>.*?</template> # a template tag
)
|
</?(?:%s)+ # opening tag (\'<tag\') or closing tag (\'</tag\')
(?:
(?:
(?:
\\s+\\w[\\w-]* # EITHER spaces, followed by attribute names
(?:
\\s*=?\\s* # equals
(?>
".*?" # attribute values in double-quotes
|
\'.*?\' # attribute values in single-quotes
|
[^\'">\\s]+ # plain attribute values
)
)?
)
| # OR a single dash (for TYPO3 link tag)
(?:
\\s+-
)
)+\\s*
| # OR only spaces
\\s*
)
/?> # closing the tag with \'>\' or \'/>\'
)';
public function __construct(
private LoggerInterface $logger,
) {}
/**
* Implements "cropHTML" which is a modified "substr" function allowing to limit a string length to a certain number
* of chars (from either start or end of string) and having a pre/postfix applied if the string really was cropped.
*
* @param string $content The string to perform the operation on
* @param int $numberOfChars Max number of chars of the string. Negative value means cropping from end of string.
* @param string $replacementForEllipsis The pre/postfix string to apply if cropping occurs.
* @param bool $cropToSpace If true then crop will be applied at nearest space.
* @return string The processed input value.
*/
public function crop(string $content, int $numberOfChars, string $replacementForEllipsis, bool $cropToSpace): string
{
$cropFromRight = $numberOfChars < 0;
$sections = $this->splitContentIntoSections($content, $cropFromRight);
// Only crop text sections (chars of tag-blocks are not counted).
$strLengthOfAllPrevTextSections = 0;
// This is the offset of the content item which was cropped.
$croppedOffset = null;
$amountOfSections = count($sections);
// For cropSectionToNextSpace we need a collection of all processed text sections
$processedTextSectionsForCropping = [];
for ($offset = 0; $offset < $amountOfSections; $offset++) {
if ($this->isTextSection($offset)) {
$contentOfCurrentSection = $sections[$offset];
$strLengthOfCurrentSection = mb_strlen(
html_entity_decode($contentOfCurrentSection, ENT_COMPAT, 'UTF-8'),
'utf-8'
);
if ($strLengthOfAllPrevTextSections + $strLengthOfCurrentSection > abs($numberOfChars)) {
$croppedOffset = $offset;
$cropPosition = $this->getCropPosition(
$contentOfCurrentSection,
$numberOfChars,
$strLengthOfAllPrevTextSections,
$cropFromRight
);
// Main cropping. Note the +1 and -1. These are there to be able to
// check for space characters later on.
$contentOfCurrentSection = !$cropFromRight
? mb_substr($contentOfCurrentSection, 0, $cropPosition + 1)
: mb_substr($contentOfCurrentSection, -$cropPosition - 1);
$contentOfCurrentSection = $this->cropSectionToNextSpace(
$contentOfCurrentSection,
$processedTextSectionsForCropping,
$cropToSpace,
$cropFromRight
);
$sections[$offset] = $contentOfCurrentSection;
break;
}
$strLengthOfAllPrevTextSections += $strLengthOfCurrentSection;
if ($contentOfCurrentSection !== '') {
$processedTextSectionsForCropping[] = $contentOfCurrentSection;
}
}
}
$sections = $this->closeCroppedTags($sections, $croppedOffset, $numberOfChars, $replacementForEllipsis);
// Reverse array once again if we are cropping from the end.
if ($numberOfChars < 0) {
$sections = array_reverse($sections);
}
return implode('', $sections);
}
/**
* Split $content into an array(even items in the array are outside the tags, odd numbers are tag-blocks).
*/
protected function splitContentIntoSections(string $content, bool $cropFromRight): array
{
$splitPattern = sprintf(
self::TAGS_REG_EXP,
self::TAGS
);
$sections = preg_split(
'%' . $splitPattern . '%xs',
$content,
-1,
PREG_SPLIT_DELIM_CAPTURE
);
if ($sections === false) {
$this->logger->debug('Unable to split "{content}" into tags.', ['content' => $content]);
$sections = [];
}
// Reverse array if we are cropping from right.
if ($cropFromRight) {
$sections = array_reverse($sections);
}
return $sections;
}
protected function getCropPosition(
string $contentOfCurrentSection,
int $numberOfChars,
int $strLengthOfAllPrevTextSections,
bool $cropFromRight
): int {
$cropPosition = abs($numberOfChars) - $strLengthOfAllPrevTextSections;
// The snippet "&[^&\s;]{2,8};" in the RegEx below represents entities.
$entityPattern = '/&[^&\\s;]{2,8};/';
preg_match_all($entityPattern, $contentOfCurrentSection, $matches);
$entityMatches = $matches[0];
// If we have found any html entities, these should be counted as 1 character.
// Strategy is to replace all found entities with an arbitrary character ($)
// and use this new string to count offsets.
if ($entityMatches !== []) {
$escapedContent = str_replace('$', ' ', $contentOfCurrentSection);
$replacedContent = preg_replace($entityPattern, '$', $escapedContent, -1);
$croppedContent = !$cropFromRight
? mb_substr($replacedContent, 0, $cropPosition)
: mb_substr($replacedContent, $numberOfChars, $cropPosition);
// In case of negative offsets, we need to reverse everything.
// Because the string is cropped from behind, the entities
// have to be replaced in reverse, too.
if ($cropFromRight) {
$croppedContent = strrev($croppedContent);
$entityMatches = array_reverse($entityMatches);
}
foreach ($entityMatches as $entity) {
$croppedContent = preg_replace('/\$/', $entity, $croppedContent, 1);
}
$cropPosition = mb_strlen($croppedContent);
}
return $cropPosition;
}
protected function closeCroppedTags(
array $sections,
?int $croppedOffset,
int $numberOfChars,
string $replacementForEllipsis
): array {
$closingTags = [];
if ($croppedOffset !== null) {
$openingTagRegEx = '#^<(\\w+)(?:\\s|>)#';
$closingTagRegEx = '#^</(\\w+)(?:\\s|>)#';
for ($offset = $croppedOffset - 1; $offset >= 0; $offset = $offset - 2) {
if (str_ends_with($sections[$offset], '/>')) {
// Ignore empty element tags (e.g. <br />).
continue;
}
preg_match($numberOfChars < 0 ? $closingTagRegEx : $openingTagRegEx, $sections[$offset], $matches);
$tagName = $matches[1] ?? null;
if ($tagName !== null) {
// Seek for the closing (or opening) tag.
$amountOfSections = count($sections);
for ($seekingOffset = $offset + 2; $seekingOffset < $amountOfSections; $seekingOffset = $seekingOffset + 2) {
preg_match($numberOfChars < 0 ? $openingTagRegEx : $closingTagRegEx, $sections[$seekingOffset], $matches);
$seekingTagName = $matches[1] ?? null;
if ($tagName === $seekingTagName) {
// We found a matching tag.
// Add closing tag only if it occurs after the cropped content item.
if ($seekingOffset > $croppedOffset) {
$closingTags[] = $sections[$seekingOffset];
}
break;
}
}
}
}
// Drop the cropped items of the content array. The $closingTags will be added later on again.
array_splice($sections, $croppedOffset + 1);
}
return array_merge($sections, [
$croppedOffset !== null ? trim($replacementForEllipsis) : '',
], $closingTags);
}
protected function cropSectionToNextSpace(
string $contentOfCurrentSection,
array $processedTextSectionsForCropping,
bool $cropToSpace,
bool $cropFromRight
): string {
// Crop to space means, we ensure to crop before (or after) a space.
// If there are no spaces, this option has no effect.
$cropToSpaceApplied = false;
if ($cropToSpace) {
$exploded = explode(' ', $contentOfCurrentSection);
if (!$cropFromRight) {
array_unshift(
$exploded,
...$processedTextSectionsForCropping
);
} else {
array_push(
$exploded,
...$processedTextSectionsForCropping
);
}
if (count($exploded) > 1) {
if (!$cropFromRight && $exploded[count($exploded) - 1] !== ' ') {
array_pop($exploded);
$cropToSpaceApplied = true;
} elseif ($exploded[0] !== ' ') {
array_shift($exploded);
$cropToSpaceApplied = true;
}
}
$exploded = array_diff($exploded, $processedTextSectionsForCropping);
$contentOfCurrentSection = implode(' ', $exploded);
}
// Only remove the extra character again, if crop2space did not apply anything.
if (!$cropToSpaceApplied) {
$contentOfCurrentSection = !$cropFromRight
? mb_substr($contentOfCurrentSection, 0, -1)
: mb_substr($contentOfCurrentSection, 1);
}
return $contentOfCurrentSection;
}
protected function isTextSection(int $offset): bool
{
return $offset % 2 === 0;
}
}
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
<?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\Core\Html;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\HtmlSanitizer\Behavior;
use TYPO3\HtmlSanitizer\Behavior\Attr\UriAttrValueBuilder;
use TYPO3\HtmlSanitizer\Builder\BuilderInterface;
use TYPO3\HtmlSanitizer\Sanitizer;
use TYPO3\HtmlSanitizer\Visitor\CommonVisitor;
/**
* Builder, creating a `Sanitizer` instance for "i18n"
* behavior for tags, attributes and values. Basically used
* for language labels containing HTML.
*
* @internal
*/
readonly class I18nSanitizerBuilder implements BuilderInterface
{
public function build(): Sanitizer
{
$globalAttrs = $this->createGlobalAttrs();
$httpUriBuilder = GeneralUtility::makeInstance(UriAttrValueBuilder::class)
->allowSchemes('http', 'https');
$behavior = GeneralUtility::makeInstance(Behavior::class)
->withTags(
(new Behavior\Tag('a', Behavior\Tag::ALLOW_CHILDREN))
->addAttrs(...$globalAttrs)
->addAttrs(...$this->createAttrs('rel', 'target'))
->addAttrs(
(new Behavior\Attr('href'))
->withValues(...$httpUriBuilder->getValues()),
),
(new Behavior\Tag('b', Behavior\Tag::ALLOW_CHILDREN))
->addAttrs(...$globalAttrs),
(new Behavior\Tag('br'))
->addAttrs(...$globalAttrs),
(new Behavior\Tag('div', Behavior\Tag::ALLOW_CHILDREN))
->addAttrs(...$globalAttrs),
(new Behavior\Tag('em', Behavior\Tag::ALLOW_CHILDREN))
->addAttrs(...$globalAttrs),
(new Behavior\Tag('i', Behavior\Tag::ALLOW_CHILDREN))
->addAttrs(...$globalAttrs),
(new Behavior\Tag('li', Behavior\Tag::ALLOW_CHILDREN))
->addAttrs(...$globalAttrs),
(new Behavior\Tag('span', Behavior\Tag::ALLOW_CHILDREN))
->addAttrs(...$globalAttrs),
(new Behavior\Tag('strong', Behavior\Tag::ALLOW_CHILDREN))
->addAttrs(...$globalAttrs),
(new Behavior\Tag('ul', Behavior\Tag::ALLOW_CHILDREN))
->addAttrs(...$globalAttrs)
);
$visitor = GeneralUtility::makeInstance(CommonVisitor::class, $behavior);
return GeneralUtility::makeInstance(Sanitizer::class, $behavior, $visitor);
}
/**
* @return Behavior\Attr[]
*/
protected function createGlobalAttrs(): array
{
// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes
$attrs = $this->createAttrs(
'class',
'role',
'tabindex',
'title',
);
$attrs[] = new Behavior\Attr('aria-', Behavior\Attr::NAME_PREFIX);
$attrs[] = new Behavior\Attr('data-', Behavior\Attr::NAME_PREFIX);
return $attrs;
}
/**
* @return Behavior\Attr[]
*/
protected function createAttrs(string ...$names): array
{
return array_map(
static function (string $name): Behavior\Attr {
return new Behavior\Attr($name);
},
$names
);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?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\Core\Html;
use TYPO3\CMS\Core\Html\Visitor\UnwrapTagVisitor;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\HtmlSanitizer\Builder\CommonBuilder;
use TYPO3\HtmlSanitizer\Sanitizer;
use TYPO3\HtmlSanitizer\Visitor\CommonVisitor;
/**
* Builder, creating a `Sanitizer` instance for previews in backend, skipping any links
*
* @internal
*/
class PreviewSanitizerBuilder extends CommonBuilder implements SingletonInterface
{
public function build(): Sanitizer
{
$behavior = $this->createBehavior();
$visitor = GeneralUtility::makeInstance(CommonVisitor::class, $behavior);
$unwrapTagVisitor = GeneralUtility::makeInstance(UnwrapTagVisitor::class);
return GeneralUtility::makeInstance(Sanitizer::class, $behavior, $visitor, $unwrapTagVisitor);
}
}
+873
View File
@@ -0,0 +1,873 @@
<?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\Core\Html;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Html\Event\AfterTransformTextForPersistenceEvent;
use TYPO3\CMS\Core\Html\Event\AfterTransformTextForRichTextEditorEvent;
use TYPO3\CMS\Core\Html\Event\BeforeTransformTextForPersistenceEvent;
use TYPO3\CMS\Core\Html\Event\BeforeTransformTextForRichTextEditorEvent;
use TYPO3\CMS\Core\Html\Event\BrokenLinkAnalysisEvent;
use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\HtmlSanitizer\Builder\BuilderInterface;
/**
* Class for parsing HTML for the Rich Text Editor. (also called transformations)
*
* Concerning line breaks:
* Regardless if LF (Unix-style) or CRLF (Windows) was put in, the HtmlParser works with LFs and migrates all
* line breaks to LFs internally, however when all transformations are done, all LFs are transformed to CRLFs.
* This means: RteHtmlParser always returns CRLFs to be maximum compatible with all formats.
*/
#[Autoconfigure(public: true)]
class RteHtmlParser extends HtmlParser
{
/**
* List of elements that are not wrapped into a "p" tag while doing the transformation.
*/
protected string $blockElementList = 'DIV,TABLE,BLOCKQUOTE,PRE,UL,OL,H1,H2,H3,H4,H5,H6,ADDRESS,DL,DD,HEADER,SECTION,FOOTER,NAV,ARTICLE,ASIDE,FIGURE,FIGCAPTION';
/**
* List of all tags that are allowed by default
*/
protected string $defaultAllowedTagsList = 'b,i,u,a,img,br,div,center,pre,figure,figcaption,font,hr,sub,sup,p,strong,em,li,ul,ol,blockquote,strike,span,abbr,acronym,dfn,s,mark';
/**
* Set to the TSconfig options coming from page TSconfig
*/
protected array $procOptions = [];
/**
* Run-away brake for recursive calls.
*/
protected int $TS_transform_db_safecounter = 100;
/**
* Data caching for processing function
*/
protected array $getKeepTags_cache = [];
/**
* Storage of the allowed CSS class names in the RTE
*/
protected array $allowedClasses = [];
/**
* A list of HTML attributes for <p> tags. Because <p> tags are wrapped currently in a special handling,
* they have a special place for configuration via 'proc.keepPDIVattribs'
*/
protected array $allowedAttributesForParagraphTags = [
'class',
'align',
'id',
'title',
'dir',
'lang',
'xml:lang',
'itemscope',
'itemtype',
'itemprop',
];
/**
* Any tags that are allowed outside of <p> sections - usually similar to the block elements
* plus some special tags like <hr> and <img> (if images are allowed).
* Completely overrideable via 'proc.allowTagsOutside'
*/
protected array $allowedTagsOutsideOfParagraphs = [
'address',
'article',
'aside',
'blockquote',
'div',
'footer',
'figure',
'figcaption',
'header',
'hr',
'nav',
'section',
];
public function __construct(
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly LoggerInterface $logger,
protected readonly LinkService $linkService,
) {}
/**
* Sanitize and streamline given options (usually from RichTextConfiguration results "proc."
* and set them to the respective properties.
*/
protected function setProcessingConfiguration(array $processingConfiguration): void
{
$this->procOptions = $processingConfiguration;
$this->getKeepTags_cache = [];
if (isset($this->procOptions['allowedClasses.'])) {
$this->allowedClasses = (array)$this->procOptions['allowedClasses.'];
} else {
$this->allowedClasses = GeneralUtility::trimExplode(',', $this->procOptions['allowedClasses'] ?? '', true);
}
// Dynamic configuration of blockElementList
if (!empty($this->procOptions['blockElementList'])) {
if (!isset($this->procOptions['blockElementList.'])) {
$blockElementList = GeneralUtility::trimExplode(',', $this->procOptions['blockElementList'], true);
} else {
$blockElementList = (array)$this->procOptions['blockElementList.'];
}
$this->blockElementList = implode(',', $blockElementList);
}
// Define which attributes are allowed on <p> tags
if (isset($this->procOptions['allowAttributes.'])) {
$this->allowedAttributesForParagraphTags = $this->procOptions['allowAttributes.'];
}
// Override tags which are allowed outside of <p> tags
if (isset($this->procOptions['allowTagsOutside'])) {
if (!isset($this->procOptions['allowTagsOutside.'])) {
$this->allowedTagsOutsideOfParagraphs = GeneralUtility::trimExplode(',', strtolower($this->procOptions['allowTagsOutside']), true);
} else {
$this->allowedTagsOutsideOfParagraphs = (array)$this->procOptions['allowTagsOutside.'];
}
}
}
/**
* Main entry point for transforming RTE content in the database so the Rich Text Editor can deal with
* e.g. links.
*/
public function transformTextForRichTextEditor(string $value, array $processingConfiguration): string
{
$initialValue = $value;
$this->setProcessingConfiguration($processingConfiguration);
$modes = $this->resolveAppliedTransformationModes('rte');
$beforeTransformTextForRichTextEditorEvent = new BeforeTransformTextForRichTextEditorEvent(
$value,
$initialValue,
$processingConfiguration
);
$this->eventDispatcher->dispatch($beforeTransformTextForRichTextEditorEvent);
$value = $beforeTransformTextForRichTextEditorEvent->getHtmlContent();
$value = $this->streamlineLineBreaksForProcessing($value);
// If an entry HTML cleaner was configured, pass the content through the HTMLcleaner
$value = $this->runHtmlParserIfConfigured($value, 'entryHTMLparser_rte');
// Traverse modes
foreach ($modes as $cmd) {
switch ($cmd) {
case 'detectbrokenlinks':
$value = $this->markBrokenLinks($value);
break;
case 'css_transform':
$value = $this->TS_transform_rte($value);
break;
default:
// Do nothing
}
}
// If an exit HTML cleaner was configured, pass the content through the HTMLcleaner
$value = $this->runHtmlParserIfConfigured($value, 'exitHTMLparser_rte');
// Final clean up of linebreaks
$value = $this->streamlineLineBreaksAfterProcessing($value);
$afterTransformTextForRichTextEditorEvent = new AfterTransformTextForRichTextEditorEvent(
$value,
$initialValue,
$processingConfiguration
);
$this->eventDispatcher->dispatch($afterTransformTextForRichTextEditorEvent);
return $afterTransformTextForRichTextEditorEvent->getHtmlContent();
}
/**
* Called to process HTML content before it is stored in the database.
*/
public function transformTextForPersistence(string $value, array $processingConfiguration): string
{
$initialValue = $value;
$this->setProcessingConfiguration($processingConfiguration);
$modes = $this->resolveAppliedTransformationModes('db');
$beforeTransformTextForPersistenceEvent = new BeforeTransformTextForPersistenceEvent(
$value,
$initialValue,
$processingConfiguration
);
$this->eventDispatcher->dispatch($beforeTransformTextForPersistenceEvent);
$value = $beforeTransformTextForPersistenceEvent->getHtmlContent();
$value = $this->streamlineLineBreaksForProcessing($value);
// If an entry HTML cleaner was configured, pass the content through the HTMLcleaner
$value = $this->runHtmlParserIfConfigured($value, 'entryHTMLparser_db');
// Traverse modes
foreach ($modes as $cmd) {
switch ($cmd) {
case 'detectbrokenlinks':
$value = $this->removeBrokenLinkMarkers($value);
break;
case 'ts_links':
$value = $this->TS_links_db($value);
break;
case 'css_transform':
// Transform empty paragraphs into spacing paragraphs
$value = str_replace('<p></p>', '<p>&nbsp;</p>', $value);
// Double any trailing spacing paragraph so that it does not get removed by divideIntoLines()
$value = preg_replace('/<p>&nbsp;<\/p>$/', '<p>&nbsp;</p><p>&nbsp;</p>', $value) ?? $value;
$value = $this->TS_transform_db($value);
break;
default:
// Do nothing
}
}
// process markup with HTML Sanitizer
$value = $this->htmlSanitize($value, $this->procOptions['HTMLparser_db.'] ?? []);
// If an exit HTML cleaner was configured, pass the content through the HTMLcleaner
$value = $this->runHtmlParserIfConfigured($value, 'exitHTMLparser_db');
// Final clean up of linebreaks
$value = $this->streamlineLineBreaksAfterProcessing($value);
$afterTransformTextForPersistenceEvent = new AfterTransformTextForPersistenceEvent(
$value,
$initialValue,
$processingConfiguration
);
$this->eventDispatcher->dispatch($afterTransformTextForPersistenceEvent);
return $afterTransformTextForPersistenceEvent->getHtmlContent();
}
/**
* Ensures what transformation modes should be executed, and that they are only executed once.
*
* @return array the resolved transformation modes
*/
protected function resolveAppliedTransformationModes(string $direction): array
{
// Setting modes / transformations to be called
if ((string)($this->procOptions['overruleMode'] ?? '') !== '') {
$modes = GeneralUtility::trimExplode(',', $this->procOptions['overruleMode']);
} else {
$modes = [$this->procOptions['mode']];
}
$modeList = implode(',', $modes);
// Replace the shortcut "default" with all custom modes
$modeList = str_replace('default', 'detectbrokenlinks,css_transform,ts_links', $modeList);
// Make list unique
$modes = array_unique(GeneralUtility::trimExplode(',', $modeList, true));
// Reverse order if direction is "rte"
if ($direction === 'rte') {
$modes = array_reverse($modes);
}
return $modes;
}
/**
* Runs the HTML parser if it is configured
* Getting additional HTML cleaner configuration. These are applied either before or after the main transformation
* is done and thus totally independent processing options you can set up.
*
* This is only possible via TSconfig (procOptions) currently.
*
* @param string $configurationDirective used to look up in the procOptions if enabled, and then fetch the
* @return string the processed content
*/
protected function runHtmlParserIfConfigured(string $content, string $configurationDirective): string
{
if (!empty($this->procOptions[$configurationDirective])) {
[$keepTags, $keepNonMatchedTags, $hscMode, $additionalConfiguration] = $this->HTMLparserConfig($this->procOptions[$configurationDirective . '.']);
$content = $this->HTMLcleaner($content, $keepTags, $keepNonMatchedTags, $hscMode, $additionalConfiguration);
}
return $content;
}
/************************************
*
* Specific RTE TRANSFORMATION functions
*
*************************************/
/**
* Transformation handler: 'ts_links' / direction: "db"
* Processing anchor tags, and resolves them correctly again via the LinkService syntax
*
* Splits content into <a> tag blocks and processes each tag, and allows hooks to actually render
* the result.
*
* @param string $value Content input
* @return string Content output
*/
protected function TS_links_db(string $value): string
{
$blockSplit = $this->splitIntoBlock('A', $value);
foreach ($blockSplit as $k => $v) {
if ($k % 2) {
[$tagAttributes] = $this->get_tag_attributes($this->getFirstTag($v), true);
// Anchors would not have a href attribute
if (!isset($tagAttributes['href'])) {
continue;
}
// Store the link as <a> tag as default by TYPO3, with the link service syntax
try {
$linkInformation = $this->linkService->resolve($tagAttributes['href']);
$tagAttributes['href'] = $this->linkService->asString($linkInformation);
} catch (UnknownLinkHandlerException $e) {
$tagAttributes['href'] = $linkInformation['href'] ?? $tagAttributes['href'];
}
$blockSplit[$k] = '<a ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . '>'
. $this->TS_links_db($this->removeFirstAndLastTag($blockSplit[$k])) . '</a>';
}
}
return implode('', $blockSplit);
}
/**
* Transformation handler: 'css_transform' / direction: "db"
* Cleaning (->db) for standard content elements (ts)
*
* @param string $value Content input
* @return string Content output
* @see TS_transform_rte()
*/
protected function TS_transform_db(string $value): string
{
// Safety... so forever loops are avoided (they should not occur, but an error would potentially do this...)
$this->TS_transform_db_safecounter--;
if ($this->TS_transform_db_safecounter < 0) {
return $value;
}
// Split the content from RTE by the occurrence of these blocks:
$blockSplit = $this->splitIntoBlock($this->blockElementList, $value);
// Avoid superfluous linebreaks by transform_db after ending headListTag
while (count($blockSplit) > 0 && trim(end($blockSplit)) === '') {
array_pop($blockSplit);
}
// Traverse the blocks
foreach ($blockSplit as $k => $v) {
if ($k % 2) {
// Inside block:
// Init:
$tag = $this->getFirstTag($v);
$tagName = strtolower($this->getFirstTagName($v));
// Process based on the tag:
switch ($tagName) {
case 'blockquote':
case 'dd':
case 'div':
case 'header':
case 'section':
case 'footer':
case 'nav':
case 'article':
case 'aside':
$blockSplit[$k] = $tag . $this->TS_transform_db($this->removeFirstAndLastTag($blockSplit[$k])) . '</' . $tagName . '>';
break;
case 'pre':
break;
default:
// usually <hx> tags and <table> tags where no other block elements are within the tags
// Eliminate true linebreaks inside block element tags
$blockSplit[$k] = preg_replace('/[' . LF . ']+/', ' ', $blockSplit[$k]);
}
} else {
// NON-block:
if (trim($blockSplit[$k]) !== '') {
$string = $blockSplit[$k];
$string = preg_replace('#<([a-z]+)/>#', '<$1 />', $string);
// Remove linebreaks preceding hr tags
$string = preg_replace('/[' . LF . ']+<(hr)(\\s[^>\\/]*)?[[:space:]]*\\/?>/', '<$1$2/>', $string) ?? '';
// Remove linebreaks following hr tags
$string = preg_replace('/<(hr)(\\s[^>\\/]*)?[[:space:]]*\\/?>[' . LF . ']+/', '<$1$2/>', $string) ?? '';
// Replace other linebreaks with space
$string = preg_replace('/[' . LF . ']+/', ' ', $string);
// process allowed/removed tags
$string = $this->HTMLcleaner(
(string)$string,
$this->getKeepTags('db'),
$this->procOptions['HTMLparser_db.']['keepNonMatchedTags'] ?? '',
(int)($this->procOptions['HTMLparser_db.']['htmlSpecialChars'] ?? 0)
);
$blockSplit[$k] = (string)$this->divideIntoLines($string);
} else {
unset($blockSplit[$k]);
}
}
}
$this->TS_transform_db_safecounter++;
return implode(LF, $blockSplit);
}
/**
* Transformation handler: css_transform / direction: "rte"
* Set (->rte) for standard content elements (ts)
*
* @param string $value Content input
* @return string Content output
* @see TS_transform_db()
*/
protected function TS_transform_rte(string $value): string
{
// Split the content from database by the occurrence of the block elements
$blockSplit = $this->splitIntoBlock($this->blockElementList, $value);
// Traverse the blocks
foreach ($blockSplit as $k => $v) {
if ($k % 2) {
// Inside one of the blocks:
// Init:
$tag = $this->getFirstTag($v);
$tagName = strtolower($this->getFirstTagName($v));
// Based on tagname, we do transformations:
switch ($tagName) {
case 'blockquote':
case 'dd':
case 'div':
case 'header':
case 'section':
case 'footer':
case 'nav':
case 'article':
case 'aside':
$blockSplit[$k] = $tag . $this->TS_transform_rte($this->removeFirstAndLastTag($blockSplit[$k])) . '</' . $tagName . '>';
break;
}
if (!isset($blockSplit[$k + 1])) {
$blockSplit[$k + 1] = '';
}
$blockSplit[$k + 1] = preg_replace('/^[ ]*' . LF . '/', '', $blockSplit[$k + 1]);
} else {
// NON-block:
$nextFTN = $this->getFirstTagName($blockSplit[$k + 1] ?? '');
$onlyLineBreaks = (preg_match('/^[ ]*' . LF . '+[ ]*$/', $blockSplit[$k]) == 1);
// If the line is followed by a block or is the last line:
if (GeneralUtility::inList($this->blockElementList, $nextFTN) || !isset($blockSplit[$k + 1])) {
// If the line contains more than just linebreaks, reduce the number of trailing linebreaks by 1
if (!$onlyLineBreaks) {
$blockSplit[$k] = preg_replace('/(' . LF . '*)' . LF . '[ ]*$/', '$1', $blockSplit[$k]);
} else {
// If the line contains only linebreaks, remove the leading linebreak
$blockSplit[$k] = preg_replace('/^[ ]*' . LF . '/', '', $blockSplit[$k]);
}
}
// If $blockSplit[$k] is blank then unset the line, unless the line only contained linebreaks
if ((string)$blockSplit[$k] === '' && !$onlyLineBreaks) {
unset($blockSplit[$k]);
} else {
$blockSplit[$k] = $this->setDivTags($blockSplit[$k]);
}
}
}
return implode(LF, $blockSplit);
}
/***************************************************************
*
* Generic RTE transformation, analysis and helper functions
*
**************************************************************/
/**
* Function for cleaning content going into the database.
* Content is cleaned eg. by removing unallowed HTML and ds-HSC content
* It is basically calling HTMLcleaner from the parent class with some preset configuration specifically set up for cleaning content going from the RTE into the db
*
* @param string $content Content to clean up
* @return string Clean content
* @see getKeepTags()
*/
protected function HTMLcleaner_db(string $content): string
{
$keepTags = $this->getKeepTags('db');
return $this->HTMLcleaner($content, $keepTags, false);
}
/**
* Creates an array of configuration for the HTMLcleaner function based on whether content
* go TO or FROM the Rich Text Editor ($direction)
*
* @param string $direction The direction of the content being processed by the output configuration; "db" (content going into the database FROM the rte) or "rte" (content going into the form)
* @return array Configuration array
* @see HTMLcleaner_db()
*/
protected function getKeepTags(string $direction): array
{
if (!isset($this->getKeepTags_cache[$direction]) || !is_array($this->getKeepTags_cache[$direction])) {
// Setting up allowed tags:
// Default is to get allowed/denied tags from internal array of processing options:
// Construct default list of tags to keep:
if (isset($this->procOptions['allowTags.']) && is_array($this->procOptions['allowTags.'])) {
$keepTags = implode(',', $this->procOptions['allowTags.']);
} else {
$keepTags = $this->procOptions['allowTags'] ?? '';
}
$keepTags = array_flip(GeneralUtility::trimExplode(',', $this->defaultAllowedTagsList . ',' . strtolower($keepTags), true));
// For tags to deny, remove them from $keepTags array:
if (!isset($this->procOptions['denyTags.'])) {
$denyTags = GeneralUtility::trimExplode(',', $this->procOptions['denyTags'] ?? '', true);
} else {
$denyTags = $this->procOptions['denyTags.'];
}
foreach ($denyTags as $dKe) {
unset($keepTags[$dKe]);
}
// Based on the direction of content, set further options:
switch ($direction) {
case 'rte':
// Transforming keepTags array so it can be understood by the HTMLcleaner function.
// This basically converts the format of the array from TypoScript (having dots) to plain multi-dimensional array.
[$keepTags] = $this->HTMLparserConfig($this->procOptions['HTMLparser_rte.'] ?? [], $keepTags);
break;
case 'db':
// Setting up span tags if they are allowed:
if (isset($keepTags['span'])) {
$keepTags['span'] = [
'allowedAttribs' => 'id,class,style,title,lang,xml:lang,dir,itemscope,itemtype,itemprop',
'fixAttrib' => [
'class' => [
'removeIfFalse' => 1,
],
],
'rmTagIfNoAttrib' => 1,
];
if (!empty($this->allowedClasses)) {
$keepTags['span']['fixAttrib']['class']['list'] = $this->allowedClasses;
}
}
// Setting further options, getting them from the processing options
$TSc = $this->procOptions['HTMLparser_db.'] ?? [];
if (empty($TSc['globalNesting'])) {
$TSc['globalNesting'] = 'b,i,u,a,center,font,sub,sup,strong,em,strike,span';
}
if (empty($TSc['noAttrib'])) {
$TSc['noAttrib'] = 'b,i,u,br,center,hr,sub,sup,strong,em,li,ul,ol,blockquote,strike';
}
// Transforming the array from TypoScript to regular array:
[$keepTags] = $this->HTMLparserConfig($TSc, $keepTags);
break;
}
// Caching (internally, in object memory) the result
$this->getKeepTags_cache[$direction] = $keepTags;
}
// Return result:
return $this->getKeepTags_cache[$direction];
}
/**
* This resolves the $value into parts based on <p>-sections. These are returned as lines separated by LF.
* This point is to resolve the HTML-code returned from RTE into ordinary lines so it's 'human-readable'
* The function ->setDivTags does the opposite.
* This function processes content to go into the database.
*
* @param string $value Value to process.
* @param int $count Recursion brake. Decremented on each recursion down to zero. Default is 5 (which equals the allowed nesting levels of p tags).
* @param bool $returnArray If TRUE, an array with the lines is returned, otherwise a string of the processed input value.
* @return string|array Processed input value.
* @see setDivTags()
*/
protected function divideIntoLines(string $value, int $count = 5, bool $returnArray = false)
{
// Setting the third param will eliminate false end-tags. Maybe this is a good thing to do...?
$paragraphBlocks = $this->splitIntoBlock('p', $value, true);
// Returns plainly the content if there was no p sections in it
if (count($paragraphBlocks) <= 1 || $count <= 0) {
return $this->sanitizeLineBreaksForContentOnly($value);
}
// Traverse the splitted sections
foreach ($paragraphBlocks as $k => $v) {
if ($k % 2) {
// Inside a <p> section
$v = $this->removeFirstAndLastTag($v);
// Fetching 'sub-lines' - which will explode any further p nesting recursively
$subLines = $this->divideIntoLines($v, $count - 1, true);
// So, if there happened to be sub-nesting of p, this is written directly as the new content of THIS section. (This would be considered 'an error')
if (is_array($subLines)) {
$paragraphBlocks[$k] = implode(LF, $subLines);
} else {
//... but if NO subsection was found, we process it as a TRUE line without erroneous content:
$paragraphBlocks[$k] = $this->processContentWithinParagraph($subLines, $paragraphBlocks[$k]);
}
// If it turns out the line is just blank (containing a &nbsp; possibly) then just make it pure blank.
// But, prevent filtering of lines that are blank in sense above, but whose tags contain attributes.
// Those attributes should have been filtered before; if they are still there they must be considered as possible content.
if (trim(strip_tags($paragraphBlocks[$k])) === '&nbsp;' && !preg_match('/\\<(img)(\\s[^>]*)?\\/?>/si', $paragraphBlocks[$k]) && !preg_match('/\\<([^>]*)?( align| class| style| id| title| dir| lang| xml:lang)([^>]*)?>/si', trim($paragraphBlocks[$k]))) {
$paragraphBlocks[$k] = '';
}
} else {
// Outside a paragraph, if there is still something in there, just add a <p> tag
// Remove positions which are outside <p> tags and without content
$paragraphBlocks[$k] = trim(strip_tags($paragraphBlocks[$k], '<' . implode('><', $this->allowedTagsOutsideOfParagraphs) . '>'));
$paragraphBlocks[$k] = $this->sanitizeLineBreaksForContentOnly($paragraphBlocks[$k]);
if ((string)$paragraphBlocks[$k] === '') {
unset($paragraphBlocks[$k]);
} else {
// add <p> tags around the content
$paragraphBlocks[$k] = str_replace(strip_tags($paragraphBlocks[$k]), '<p>' . strip_tags($paragraphBlocks[$k]) . '</p>', $paragraphBlocks[$k]);
}
}
}
return $returnArray ? $paragraphBlocks : implode(LF, $paragraphBlocks);
}
/**
* Converts all lines into <p></p>-sections (unless the line has a p - tag already)
* For processing of content going FROM database TO RTE.
*
* @param string $value Value to convert
* @return string Processed value.
* @see divideIntoLines()
*/
protected function setDivTags(string $value): string
{
// First, setting configuration for the HTMLcleaner function. This will process each line between the <div>/<p> section on their way to the RTE
$keepTags = $this->getKeepTags('rte');
// Divide the content into lines
$parts = explode(LF, $value);
foreach ($parts as $k => $v) {
// Processing of line content:
// If the line is blank, set it to &nbsp;
if (trim($parts[$k]) === '') {
$parts[$k] = '&nbsp;';
} else {
// Clean the line content, keeping unknown tags (as they can be removed in the entryHTMLparser)
$parts[$k] = $this->HTMLcleaner($parts[$k], $keepTags, 'protect');
// convert double-encoded &nbsp; into regular &nbsp; however this could also be reversed via the exitHTMLparser
// This was previously an option to disable called "dontConvAmpInNBSP_rte"
$parts[$k] = str_replace('&amp;nbsp;', '&nbsp;', $parts[$k]);
}
$partFirstTagName = strtolower($this->getFirstTagName($parts[$k]));
// Wrapping the line in <p> tags if not already wrapped and does not contain an hr tag and is not allowed outside of paragraphs.
if (!in_array($partFirstTagName, $this->allowedTagsOutsideOfParagraphs, true) && !preg_match('/<(hr)(\\s[^>\\/]*)?[[:space:]]*\\/?>/i', $partFirstTagName)) {
$testStr = strtolower(trim($parts[$k]));
if (!str_starts_with($testStr, '<div') || !str_ends_with($testStr, '</div>')) {
if (!str_starts_with($testStr, '<p') || !str_ends_with($testStr, '</p>')) {
// Only set p-tags if there is not already div or p tags:
$parts[$k] = '<p>' . $parts[$k] . '</p>';
}
}
}
}
// Implode result:
return implode(LF, $parts);
}
/**
* Used for transformation from RTE to DB
*
* Works on a single line within a <p> tag when storing into the database
* This always adds <p> tags and validates the arguments,
* additionally the content is cleaned up via the HTMLcleaner.
*
* @param string $content the content within the <p> tag
* @param string $fullContentWithTag the whole <p> tag surrounded as well
*
* @return string the full <p> tag with cleaned content
*/
protected function processContentWithinParagraph(string $content, string $fullContentWithTag): string
{
// clean up the content
$content = $this->HTMLcleaner_db($content);
// Get the <p> tag, and validate the attributes
$fTag = $this->getFirstTag($fullContentWithTag);
// Check which attributes of the <p> tag to keep attributes
if (!empty($this->allowedAttributesForParagraphTags)) {
[$tagAttributes] = $this->get_tag_attributes($fTag);
// Make sure the tag attributes only contain the ones that are defined to be allowed
$tagAttributes = array_intersect_key($tagAttributes, array_flip($this->allowedAttributesForParagraphTags));
// Only allow classes that are whitelisted in $this->allowedClasses
if (isset($tagAttributes['class']) && trim($tagAttributes['class']) !== '' && !empty($this->allowedClasses) && !in_array($tagAttributes['class'], $this->allowedClasses, true)) {
$classes = GeneralUtility::trimExplode(' ', $tagAttributes['class'], true);
$classes = array_intersect($classes, $this->allowedClasses);
if (!empty($classes)) {
$tagAttributes['class'] = implode(' ', $classes);
} else {
unset($tagAttributes['class']);
}
}
} else {
$tagAttributes = [];
}
// Remove any line break
$content = str_replace(LF, '', $content);
// Compile the surrounding <p> tag
$content = '<' . rtrim('p ' . $this->compileTagAttribs($tagAttributes)) . '>' . $content . '</p>';
return $content;
}
/**
* Wrap <hr> tags with LFs, and also remove double LFs, used when transforming from RTE to DB
*
* @return string the modified content
*/
protected function sanitizeLineBreaksForContentOnly(string $content): string
{
$content = preg_replace('/<(hr)(\\s[^>\\/]*)?[[:space:]]*\\/?>/i', LF . '<$1$2/>' . LF, $content) ?? $content;
$content = str_replace(LF . LF, LF, $content);
$content = preg_replace('/(^' . LF . ')|(' . LF . '$)/i', '', $content) ?? $content;
return $content;
}
/**
* Called before any processing / transformation is made
* Removing any CRs (char 13) and only deal with LFs (char 10) internally.
* CR has a very disturbing effect, so just remove all CR and rely on LF
*
* Historical note: Previously it was possible to disable this functionality via disableUnifyLineBreaks.
*
* @param string $content the content to process
* @return string the modified content
*/
protected function streamlineLineBreaksForProcessing(string $content): string
{
return str_replace(CR, '', $content);
}
/**
* Called after any processing / transformation was made
* just before the content is returned by the RTE parser all line breaks
* get unified to be "CRLF"s again.
*
* Historical note: Previously it was possible to disable this functionality via disableUnifyLineBreaks.
*
* @param string $content the content to process
* @return string the modified content
*/
protected function streamlineLineBreaksAfterProcessing(string $content): string
{
// Make sure no \r\n sequences has entered in the meantime
$content = $this->streamlineLineBreaksForProcessing($content);
// ... and then change all \n into \r\n
return str_replace(LF, CRLF, $content);
}
/**
* Content Transformation from DB to RTE
* Checks all <a> tags which reference a t3://page and checks if the page is available
* If not, some offensive styling is added.
*
* @return string the modified content
*/
protected function markBrokenLinks(string $content): string
{
$blocks = $this->splitIntoBlock('A', $content);
foreach ($blocks as $position => $value) {
if ($position % 2 === 0) {
continue;
}
[$attributes] = $this->get_tag_attributes($this->getFirstTag($value), true);
if (empty($attributes['href'])) {
continue;
}
try {
$hrefInformation = $this->linkService->resolve($attributes['href']);
$brokenLinkAnalysis = new BrokenLinkAnalysisEvent($hrefInformation['type'], $hrefInformation);
$this->eventDispatcher->dispatch($brokenLinkAnalysis);
if ($brokenLinkAnalysis->isBrokenLink()) {
$attributes['data-rte-error'] = $brokenLinkAnalysis->getReason();
}
} catch (InsufficientFolderAccessPermissionsException $e) {
// do nothing if user doesn't have access to the file/folder
} catch (UnknownLinkHandlerException $e) {
$attributes['data-rte-error'] = $e->getMessage();
}
// Always rewrite the block to allow the nested calling even if a page is found
$blocks[$position]
= '<a ' . GeneralUtility::implodeAttributes($attributes, true, true) . '>'
. $this->markBrokenLinks($this->removeFirstAndLastTag($blocks[$position]))
. '</a>';
}
return implode('', $blocks);
}
/**
* Content Transformation from RTE to DB
* Removes link information error attributes from <a> tags that are added to broken links
*
* @param string $content the content to process
* @return string the modified content
*/
protected function removeBrokenLinkMarkers(string $content): string
{
$blocks = $this->splitIntoBlock('A', $content);
foreach ($blocks as $position => $value) {
if ($position % 2 === 0) {
continue;
}
[$attributes] = $this->get_tag_attributes($this->getFirstTag($value), true);
if (empty($attributes['href'])) {
continue;
}
// Always remove the styling again (regardless of the page was found or not)
// so the database does not contain ugly stuff
unset($attributes['data-rte-error']);
if (isset($attributes['style'])) {
$attributes['style'] = trim(str_replace('background-color: yellow; border:2px red solid; color: black;', '', $attributes['style']));
if (empty($attributes['style'])) {
unset($attributes['style']);
}
}
$blocks[$position]
= '<a ' . GeneralUtility::implodeAttributes($attributes, true, true) . '>'
. $this->removeBrokenLinkMarkers($this->removeFirstAndLastTag($blocks[$position]))
. '</a>';
}
return implode('', $blocks);
}
protected function htmlSanitize(string $content, array $configuration): string
{
$features = GeneralUtility::makeInstance(Features::class);
// either `htmlSanitize = null` or `htmlSanitize = false`
// or feature flag `security.backend.htmlSanitizeRte` is disabled
if (array_key_exists('htmlSanitize', $configuration) && empty($configuration['htmlSanitize'])
|| !$features->isFeatureEnabled('security.backend.htmlSanitizeRte')
) {
return $content;
}
$build = $configuration['htmlSanitize.']['build'] ?? 'default';
if (class_exists($build) && is_a($build, BuilderInterface::class, true)) {
$builder = GeneralUtility::makeInstance($build);
} else {
$factory = GeneralUtility::makeInstance(SanitizerBuilderFactory::class);
$builder = $factory->build($build);
}
$sanitizer = $builder->build();
$initiator = GeneralUtility::makeInstance(SanitizerInitiator::class, static::class);
return $sanitizer->sanitize($content, $initiator);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?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\Core\Html;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\HtmlSanitizer\Builder\BuilderInterface;
/**
* Factory for creating a (sanitizer) builder instance. Corresponding presets can
* be declared in `$GLOBALS['TYPO3_CONF_VARS']['SYS']['htmlSanitizer']` like e.g.
*
* ```
* $GLOBALS['TYPO3_CONF_VARS']['SYS']['htmlSanitizer'] = [
* 'default' => \TYPO3\CMS\Core\Html\DefaultSanitizerBuilder::class,
* 'custom' => \Vendor\Package\CustomBuilder::class,
* ];
* ```
*
* @internal
*/
final readonly class SanitizerBuilderFactory
{
public function build(string $identifier): BuilderInterface
{
if (empty($GLOBALS['TYPO3_CONF_VARS']['SYS']['htmlSanitizer'][$identifier])) {
throw new \LogicException(sprintf('Undefined `htmlSanitizer` identifier `%s`', $identifier), 1624876139);
}
$builder = GeneralUtility::makeInstance($GLOBALS['TYPO3_CONF_VARS']['SYS']['htmlSanitizer'][$identifier]);
if (!$builder instanceof BuilderInterface) {
throw new \LogicException(
sprintf('Builder `%s` must implement interface `%s`', get_class($builder), BuilderInterface::class),
1624876266
);
}
return $builder;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?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\Core\Html;
use TYPO3\HtmlSanitizer\InitiatorInterface;
/**
* Initiator for HTML sanitization process, forwarded to sanitizer and used during logging.
*
* @internal
*/
class SanitizerInitiator implements InitiatorInterface
{
/**
* @var string
*/
protected $trace;
public function __construct(string $trace)
{
$this->trace = $trace;
}
public function __toString(): string
{
return $this->trace;
}
}
+83
View File
@@ -0,0 +1,83 @@
<?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\Core\Html;
/**
* @internal
*/
class SimpleNode
{
// similar to https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType
public const TYPE_ELEMENT = 1;
public const TYPE_TEXT = 3;
public const TYPE_CDATA = 4;
public const TYPE_COMMENT = 8;
/**
* @var int
*/
protected $type;
/**
* @var int
*/
protected $index;
/**
* @var string
*/
protected $string;
public static function fromString(int $type, int $index, string $string): self
{
return new self($type, $index, $string);
}
public function __construct(int $type, int $index, string $string)
{
$this->type = $type;
$this->index = $index;
$this->string = $string;
}
public function __toString(): string
{
return $this->string;
}
public function getType(): int
{
return $this->type;
}
public function getIndex(): int
{
return $this->index;
}
public function getElementName(): ?string
{
if ($this->getType() !== self::TYPE_ELEMENT) {
return null;
}
if (!preg_match('#^<(?P<name>[a-z][a-z0-9-]*)\b#i', $this->string, $matches)) {
return null;
}
return $matches['name'];
}
}
+225
View File
@@ -0,0 +1,225 @@
<?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\Core\Html;
/**
* Simple HTML node parser. The main focus is to determine "runaway nodes"
* like `<span attribute="<runaway attribute="other">` and better nod boundaries.
*
* (Most of) the behavior is similar to Mozilla's behavior on handling those nodes.
* (e.g. `div.innerHTML = 'x =<y>= z';` - but without creating closing node blocks)
*
* This parser does not resolve nested nodes - it just provides a flat node sequence.
*
* @internal
*/
class SimpleParser
{
protected ?string $attribute = null;
/**
* @var SimpleNode[]
*/
protected array $nodes = [];
protected int $currentType = SimpleNode::TYPE_TEXT;
protected string $currentData = '';
public static function fromString(string $string): self
{
return new self($string);
}
public function __construct(string $string)
{
$this->process($string);
}
/**
* @param int ...$types using `Node::TYPE_*`
* @return SimpleNode[]
*/
public function getNodes(int ...$types): array
{
if (empty($types)) {
return $this->nodes;
}
$nodes = array_filter(
$this->nodes,
static function (SimpleNode $node) use ($types): bool {
return in_array(
$node->getType(),
$types,
true
);
}
);
// reindex nodes
return array_values($nodes);
}
/**
* @param int|null $type using `Node::TYPE_*`
*/
public function getFirstNode(?int $type = null): ?SimpleNode
{
foreach ($this->nodes as $node) {
if ($type === null || $type === $node->getType()) {
return $node;
}
}
return null;
}
/**
* @param int|null $type using `Node::TYPE_*`
*/
public function getLastNode(?int $type = null): ?SimpleNode
{
foreach (array_reverse($this->nodes) as $node) {
if ($type === null || $type === $node->getType()) {
return $node;
}
}
return null;
}
/**
* Processes token sequence and creates corresponding `Node` instances.
*/
protected function process(string $string): void
{
$skip = 0;
$characters = str_split($string);
foreach ($characters as $i => $character) {
// skip tokens that already haven been processed
if ($skip > 0 && $skip-- > 0) {
continue;
}
// CDATA start
if ($character === '<'
&& $this->isType(SimpleNode::TYPE_TEXT) && substr($string, $i, 9) === '<![CDATA['
) {
$this->next(SimpleNode::TYPE_CDATA);
$this->append('<![CDATA[');
$skip = 8;
// comment start
} elseif ($character === '<'
&& $this->isType(SimpleNode::TYPE_TEXT) && substr($string, $i, 4) === '<!--'
) {
$this->next(SimpleNode::TYPE_COMMENT);
$this->append('<!--');
$skip = 3;
// element start
} elseif ($character === '<'
&& $this->isType(SimpleNode::TYPE_TEXT)
&& preg_match('#^</?[a-z]#i', substr($string, $i, 3))
) {
$this->next(SimpleNode::TYPE_ELEMENT);
$this->append($character);
// CDATA end
} elseif ($character === ']'
&& $this->isType(SimpleNode::TYPE_CDATA) && substr($string, $i, 3) === ']]>'
) {
$this->append(']]>');
$this->next(SimpleNode::TYPE_TEXT);
$skip = 2;
// comment end
} elseif ($character === '-'
&& $this->isType(SimpleNode::TYPE_COMMENT) && substr($string, $i, 3) === '-->'
) {
$this->append('-->');
$this->next(SimpleNode::TYPE_TEXT);
$skip = 2;
// element end
} elseif ($character === '>'
&& $this->isType(SimpleNode::TYPE_ELEMENT) && !$this->inAttribute()
) {
$this->append($character);
$this->next(SimpleNode::TYPE_TEXT);
// element attribute start
} elseif (($character === '"' || $character === "'")
&& $this->isType(SimpleNode::TYPE_ELEMENT) && !$this->inAttribute()
) {
$this->attribute = $character;
$this->append($character);
// element attribute end
} elseif (($character === '"' || $character === "'")
&& $this->isType(SimpleNode::TYPE_ELEMENT) && $this->attribute === $character
) {
$this->append($character);
$this->attribute = null;
// anything else (put to current type)
} else {
$this->append($character);
}
}
$this->finish();
}
/**
* Triggers creating "next" node instance, resets current state.
*/
protected function next(int $nextType): void
{
if ($this->currentData !== '') {
$this->nodes[] = SimpleNode::fromString(
$this->currentType,
count($this->nodes),
$this->currentData
);
}
$this->currentType = $nextType;
$this->currentData = '';
}
/**
* Finishes missing text node instance - anything else (all of those are
* tag-like "runaway" scenarios e.g. `<anything<!-- anything...` without being
* closed correctly - those nodes are ignored on purpose!
*/
protected function finish(): void
{
if ($this->currentData === '') {
return;
}
if ($this->isType(SimpleNode::TYPE_TEXT)) {
$this->nodes[] = SimpleNode::fromString(
$this->currentType,
count($this->nodes),
$this->currentData
);
}
// either unfinished element or comment
// (ignored on purpose)
}
protected function append(string $string): void
{
$this->currentData .= $string;
}
protected function isType(int $type): bool
{
return $this->currentType === $type;
}
protected function inAttribute(): bool
{
return $this->attribute !== null;
}
}
@@ -0,0 +1,71 @@
<?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\Core\Html\Srcset;
/**
* Represents a source candidate for the HTML srcset attribute
* using the "x" unit (relative pixel density, like "1x" or "2x")
*/
final class DensitySrcsetCandidate extends SrcsetCandidate
{
public function __construct(
protected float $density,
protected ?int $referenceWidth = null
) {}
public function getDensity(): float
{
return $this->density;
}
public function setDensity(float $density): static
{
$this->density = $density;
return $this;
}
public function getReferenceWidth(): ?int
{
return $this->referenceWidth;
}
/**
* The absolute $referenceWidth will be used as "1x" width.
*/
public function setReferenceWidth(int $referenceWidth): static
{
$this->referenceWidth = $referenceWidth;
return $this;
}
public function getDescriptor(): string
{
return $this->density . static::DENSITY_UNIT;
}
public function getCalculatedWidth(): int
{
if ($this->referenceWidth === null) {
throw new \InvalidArgumentException(sprintf(
'Reference width needs to be specified if pixel density descriptors (e. g. 2x) are used in srcset: %s',
$this->getDescriptor()
), 1697743145);
}
return (int)($this->density * $this->referenceWidth);
}
}
+92
View File
@@ -0,0 +1,92 @@
<?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\Core\Html\Srcset;
/**
* Generates a HTML srcset attribute for responsive images
*/
final class SrcsetAttribute
{
/** @var SrcsetCandidate[] */
private array $candidates = [];
private string $candidateType;
/**
* @param string[] $descriptors Array of srcset width (like "300w, 500w") or density (like "1x, 2x")
* descriptors.
* @param int|null $referenceWidth Needs to be provided if $descriptors contains density descriptors
* (like "2x") to be able to resolve the relative image dimensions.
* The absolute $referenceWidth will be used as "1x" width.
*/
public static function createFromDescriptors(array $descriptors, ?int $referenceWidth = null): SrcsetAttribute
{
$generator = new SrcsetAttribute();
foreach ($descriptors as $descriptor) {
$generator->addCandidate(SrcsetCandidate::createFromDescriptor((string)$descriptor, $referenceWidth));
}
return $generator;
}
public function addCandidate(SrcsetCandidate $candidate): static
{
if (!$this->isValidCandidate($candidate)) {
throw new \InvalidArgumentException(sprintf(
'Invalid mix of w and x descriptors in srcset: %s, ..., %s',
$this->candidates[0]->generateSrcset(),
$candidate->generateSrcset()
), 1697745459);
}
$this->candidates[] = $candidate;
return $this;
}
/**
* @return SrcsetCandidate[]
*/
public function getCandidates(): array
{
return $this->candidates;
}
public function generateSrcset(): string
{
$uniqueSrcset = [];
foreach ($this->candidates as $candidate) {
$uniqueSrcset[$candidate->getDescriptor()] = $candidate->generateSrcset();
}
return implode(', ', $uniqueSrcset);
}
public function __toString()
{
return $this->generateSrcset();
}
private function isValidCandidate(SrcsetCandidate $candidate): bool
{
if (!isset($this->candidateType)) {
$this->candidateType = ($candidate instanceof DensitySrcsetCandidate)
? DensitySrcsetCandidate::class
: WidthSrcsetCandidate::class;
return true;
}
return $candidate instanceof $this->candidateType;
}
}
+94
View File
@@ -0,0 +1,94 @@
<?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\Core\Html\Srcset;
/**
* Represents a source candidate for the HTML srcset attribute
*/
abstract class SrcsetCandidate
{
public const WIDTH_UNIT = 'w';
public const DENSITY_UNIT = 'x';
protected ?string $uri = null;
/**
* @param string $descriptor srcset width (like "300w") or density (like "2x") descriptor.
* @param int|null $referenceWidth Needs to be provided if $descriptor is a density descriptor
* (like "2x") to be able to resolve the relative image dimensions.
* The absolute $referenceWidth will be used as "1x" width.
*/
public static function createFromDescriptor(string $descriptor, ?int $referenceWidth = null): SrcsetCandidate
{
$mode = substr($descriptor, -1);
$value = substr($descriptor, 0, -1);
if (is_numeric($value)) {
if ($mode === static::DENSITY_UNIT) {
// '1.5x'
return new DensitySrcsetCandidate((float)$value, $referenceWidth);
}
if ($mode === static::WIDTH_UNIT) {
// '200w'
return new WidthSrcsetCandidate((int)$value);
}
}
throw new \InvalidArgumentException(
'Invalid srcset descriptor provided, must be a numeric value that ends with "w" or "x": ' . $descriptor,
1774527269,
);
}
abstract public function getDescriptor(): string;
abstract public function getCalculatedWidth(): int;
public function setUri(string $uri): static
{
$this->uri = $uri;
return $this;
}
public function getUri(): ?string
{
return $this->uri;
}
/**
* Ensures that the provided URI can be used safely in a srcset attribute
*/
public function getSanitizedUri(): ?string
{
if ($this->uri === null) {
return null;
}
return strtr($this->uri, [
' ' => '%20',
',' => '%2C',
]);
}
public function generateSrcset(): string
{
return $this->getSanitizedUri() . ' ' . $this->getDescriptor();
}
public function __toString()
{
return $this->generateSrcset();
}
}
@@ -0,0 +1,43 @@
<?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\Core\Html\Srcset;
/**
* Represents a source candidate for the HTML srcset attribute
* using the "w" unit (absolute width in image pixels, like "200w")
*/
final class WidthSrcsetCandidate extends SrcsetCandidate
{
public function __construct(protected int $width) {}
public function setWidth(int $width): static
{
$this->width = $width;
return $this;
}
public function getCalculatedWidth(): int
{
return $this->width;
}
public function getDescriptor(): string
{
return $this->width . static::WIDTH_UNIT;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?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\Core\Html\Visitor;
use TYPO3\HtmlSanitizer\Context;
use TYPO3\HtmlSanitizer\Visitor\VisitorInterface;
/**
* Visitor to remove tags but keep its content
*
* @internal
*/
final class UnwrapTagVisitor implements VisitorInterface
{
private const array UNWRAP_TAGS = [
'a',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
];
public function beforeTraverse(Context $context) {}
public function enterNode(\DOMNode $domNode): ?\DOMNode
{
if (
!$domNode instanceof \DOMElement
|| !in_array(strtolower($domNode->tagName), self::UNWRAP_TAGS, true)
) {
return $domNode;
}
$parent = $domNode->parentNode;
if ($parent === null) {
return null;
}
// Move all children before the current node
while ($domNode->firstChild !== null) {
$parent->insertBefore($domNode->firstChild, $domNode);
}
// Remove the wrapping element
return null;
}
public function leaveNode(\DOMNode $domNode): \DOMNode
{
return $domNode;
}
public function afterTraverse(Context $context) {}
}