223 lines
9.3 KiB
PHP
223 lines
9.3 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.
|
||
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;
|
||
}
|
||
}
|