[], 'br' => [],
'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [],
'code' => [], 'mark' => [], 'sup' => [], 'sub' => [],
'ul' => [], 'ol' => [], 'li' => [],
'a' => ['href', 'target'],
];
/** Tags whose content is dropped along with them; unwrapping them would leak code as text. */
private const DROP_ENTIRELY = ['script', 'style', 'iframe', 'object', 'embed', 'template', 'noscript', 'svg', 'math'];
public const MAX_LENGTH = 20000;
/** True when a document version stores formatted text instead of plain text. */
public static function supports(mixed $schemaVersion): bool
{
return is_int($schemaVersion) && $schemaVersion >= 12;
}
/** Ueberschriften duerfen erst ab v17 Auszeichnungen tragen. */
public static function headings(mixed $schemaVersion): bool
{
return is_int($schemaVersion) && $schemaVersion >= 17;
}
/**
* Returns the text with only allowed markup left. The result is safe to print unescaped.
*/
public function sanitise(string $html): string
{
$html = trim($html);
if ('' === $html) {
return '';
}
if (!str_contains($html, '<')) {
// Plain text: escape it and keep line breaks, so typing without formatting still works.
return nl2br(htmlspecialchars($html, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), false);
}
$document = new \DOMDocument();
$previous = libxml_use_internal_errors(true);
// The wrapper gives the fragment a single root; the meta charset keeps UTF-8 intact.
$document->loadHTML(
'
'
.mb_substr($html, 0, self::MAX_LENGTH).'
',
LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING,
);
libxml_clear_errors();
libxml_use_internal_errors($previous);
$root = $document->getElementById('kant-root');
if (null === $root) {
return '';
}
$this->clean($root);
// Both branches end up with the same representation of a line break. Otherwise a text
// that happens to contain "<" would keep literal newlines while everything else uses
//
, and the two would drift apart the moment the CSS changes.
$this->breakLines($root, $document);
$out = '';
foreach ($root->childNodes as $child) {
$out .= $document->saveHTML($child);
}
return trim($out);
}
/** Walks the tree once, bottom up, so unwrapping cannot skip a child. */
private function clean(\DOMNode $node): void
{
foreach (iterator_to_array($node->childNodes) as $child) {
if ($child instanceof \DOMText) {
continue;
}
if ($child instanceof \DOMComment) {
$child->parentNode?->removeChild($child);
continue;
}
if (!$child instanceof \DOMElement) {
// Processing instructions, CDATA and anything else has no place in a text block.
$child->parentNode?->removeChild($child);
continue;
}
$name = strtolower($child->nodeName);
if (in_array($name, self::DROP_ENTIRELY, true)) {
$child->parentNode?->removeChild($child);
continue;
}
$this->clean($child);
if (!isset(self::ALLOWED[$name])) {
$this->unwrap($child);
continue;
}
foreach (iterator_to_array($child->attributes ?? []) as $attribute) {
if (!in_array(strtolower($attribute->nodeName), self::ALLOWED[$name], true)) {
$child->removeAttribute($attribute->nodeName);
}
}
if ('a' === $name) {
$href = trim($child->getAttribute('href'));
if (!$this->safeHref($href)) {
// An unusable target makes the link a span of text, not a dead link.
$this->unwrap($child);
continue;
}
$child->setAttribute('href', $href);
if ('_blank' !== $child->getAttribute('target')) {
$child->removeAttribute('target');
}
if ('_blank' === $child->getAttribute('target') || str_starts_with($href, 'https://')) {
$child->setAttribute('rel', 'noopener');
}
}
}
}
/** Turns newlines inside text into
, except inside code where they are meaningful. */
private function breakLines(\DOMNode $node, \DOMDocument $document): void
{
foreach (iterator_to_array($node->childNodes) as $child) {
if ($child instanceof \DOMElement) {
if ('code' !== strtolower($child->nodeName)) {
$this->breakLines($child, $document);
}
continue;
}
if (!$child instanceof \DOMText || !str_contains($child->nodeValue ?? '', "\n")) {
continue;
}
$parts = preg_split('/\R/', $child->nodeValue);
$parent = $child->parentNode;
foreach ($parts as $index => $part) {
if ($index > 0) {
$parent?->insertBefore($document->createElement('br'), $child);
}
if ('' !== $part) {
$parent?->insertBefore($document->createTextNode($part), $child);
}
}
$parent?->removeChild($child);
}
}
/** Replaces an element with its children, so the text stays and only the tag disappears. */
private function unwrap(\DOMElement $element): void
{
$parent = $element->parentNode;
if (null === $parent) {
return;
}
foreach (iterator_to_array($element->childNodes) as $child) {
$parent->insertBefore($child, $element);
}
$parent->removeChild($element);
}
private function safeHref(string $href): bool
{
if ('' === $href || strlen($href) > 2048 || preg_match('/[\x00-\x20\\\\]/', $href)) {
return false;
}
return (str_starts_with($href, '/') && !str_starts_with($href, '//'))
|| 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]*$/D', $href)
|| (str_starts_with($href, 'https://') && false !== filter_var($href, FILTER_VALIDATE_URL))
|| 1 === preg_match('/^mailto:[^@\s]+@[^@\s]+$/D', $href)
|| 1 === preg_match('/^tel:\+?[0-9 ()-]{3,32}$/D', $href);
}
/**
* Sanitises every rich text field of a document. Called before storing, so the database never
* holds markup the allowlist would reject.
*/
public function normaliseDocument(array $document): array
{
if (!self::supports($document['schemaVersion'] ?? null)) {
return $document;
}
// Die Fassung wird in die Schliessung gebunden. Ohne das war sie darin schlicht nicht
// vorhanden, der Filter fuer Ueberschriften lief nie - und ihr Inhalt wird unescaped
// ausgegeben. Der Test, der ein