Umut: "Beim Startseitenbild wird der Text & umgewandelt, aber in der Ansicht nach dem Speichern wird & daraus." RichText::sanitise() treated text without a single tag as plain text and only escaped it. What the editor sends is HTML, tag or no tag - so "Freudenstadt & Umgebung" gained one level of encoding on every save (&amp;, &amp;amp; ...) until the entity stood on the page for everyone to read. The draft of the kant-it.de home page had already reached three levels. The same branch carried a second fault of the same kind: nl2br() left the newline behind the <br>, and the next pass turned it into a second <br> via breakLines() - one paragraph became two. The branch now decodes first and replaces the line break instead of adding to it, so it ends up with the same representation as the DOM branch and both are stable under repeated saves. scripts/repair-doubled-entities.php cleans up what is already stored: the published and draft revision of every page and the areas, with a dry run, each change checked against the validator. Older revisions stay as history. DEEG: favicon is now its own square mark - the roof gable from the logo, white on the logo blue, rendered from logo-deeg.png - instead of the centre crop of the wide logo, which showed the "EE" of the wordmark. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
10 KiB
PHP
235 lines
10 KiB
PHP
<?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.
|
||
//
|
||
// Erst dekodieren: Was aus dem Editor kommt, ist HTML - auch ohne ein einziges Tag.
|
||
// „Freudenstadt & Umgebung“ meint ein kaufmaennisches Und. Ohne diesen Schritt
|
||
// wuerde daraus bei jedem Speichern eine Entity mehr (&amp;, &amp;amp; ...),
|
||
// bis sie sichtbar auf der Seite steht. Der Zweig mit Tags macht es ueber den DOM
|
||
// laengst richtig; dieser hier zog nach.
|
||
$text = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||
|
||
// Der Umbruch wird ersetzt, nicht ergaenzt: nl2br() liess den Zeilenumbruch
|
||
// hinter dem <br> stehen, und beim naechsten Speichern machte breakLines()
|
||
// daraus ein zweites <br> - aus einem Absatz wurden zwei. So enden beide Zweige
|
||
// bei derselben Darstellung.
|
||
return str_replace(["\r\n", "\r", "\n"], '<br>', htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
|
||
}
|
||
|
||
$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;
|
||
}
|
||
}
|