Files
KantSites/src/Content/RichText.php
T
kantarci 6d21c452bd
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
feat: add new-tab links and removable media previews (1.7.3)
2026-09-14 11:48:44 +02:00

223 lines
9.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Content;
/**
* The allowlist for formatted text.
*
* Text blocks may carry a little markup from document schema v12 on. The decision what is allowed
* is made **here, on the server, when the text is stored** – never in the editor and never at
* render time. Two reasons: whatever reaches the database is already safe, so no renderer can
* forget to filter, and a manipulated request cannot store something the editor would not offer.
*
* Everything outside the list is unwrapped, not deleted: the text survives, only the markup goes.
* A pasted Word document therefore loses its styling instead of its content.
*/
final class RichText
{
/** Tags that may stay, each with the attributes it may keep. */
private const ALLOWED = [
'p' => [], '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(
'<?xml encoding="UTF-8"><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body><div id="kant-root">'
.mb_substr($html, 0, self::MAX_LENGTH).'</div></body></html>',
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
// <br>, 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 <br>, 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 <script> in eine Ueberschrift schreibt, hat es gezeigt.
$headings = self::headings($document['schemaVersion'] ?? null);
$walk = function (array $blocks) use (&$walk, $headings): array {
foreach ($blocks as $index => $block) {
if ('text' === ($block['type'] ?? null) && is_string($block['props']['text'] ?? null)) {
$blocks[$index]['props']['text'] = $this->sanitise($block['props']['text']);
}
// Ab v17 darf auch eine Ueberschrift Auszeichnungen tragen. Gebraucht wird das
// fuer ein hervorgehobenes Wort - im Original von kant-it.de ist die zweite
// Zeile der Hauptueberschrift rot. Durch denselben Filter wie jeder Text.
if ($headings && 'heading' === ($block['type'] ?? null) && is_string($block['props']['text'] ?? null)) {
$blocks[$index]['props']['text'] = $this->sanitise($block['props']['text']);
}
if (isset($block['children']) && is_array($block['children'])) {
$blocks[$index]['children'] = $walk($block['children']);
}
}
return $blocks;
};
$document['blocks'] = $walk($document['blocks'] ?? []);
return $document;
}
}