Files
survey-bundle/src/Service/SurveyHtmlSanitizer.php
T
Claude Fable 5 833a8964cf Add topic sections, ordered inserts, rich-text descriptions and grouped results (Ausbaustufe 3)
- section content type with structure service, reader context and grouped results/PDF/Excel
- new questions append at the end (backend oncreate) and per-section add buttons
- rich-text descriptions: Quill 2 (frontend), reduced TinyMCE (backend), server-side whitelist sanitizer
- externalize inline assets, bundle Chart.js locally, harden debug access, validate range bounds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 07:57:40 +02:00

120 lines
4.0 KiB
PHP

<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerAction;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
/**
* Serverseitige Whitelist für Rich-Text-Beschreibungen (Umfrage-Einleitung,
* Fragen-Beschreibung): Absätze, Zeilenumbrüche, Listen, Fett, Kursiv.
* Alles andere (Links, Bilder, Schriftarten, Inline-Styles, Skripte) wird entfernt.
*/
final class SurveyHtmlSanitizer
{
private const ALLOWED_ELEMENTS = ['p', 'br', 'ul', 'ol', 'li', 'strong', 'em', 'b', 'i'];
private const DROPPED_ELEMENTS = ['script', 'style', 'head', 'title', 'iframe', 'object', 'embed', 'textarea', 'select', 'template', 'noscript', 'svg', 'math'];
private ?HtmlSanitizer $sanitizer = null;
public function sanitize(?string $html): string
{
$html = trim((string) $html);
if ('' === $html) {
return '';
}
// Klartext ohne Tags (Altdaten aus dem bisherigen Textarea): Absätze aus
// Leerzeilen bilden, einfache Umbrüche als <br> erhalten.
if (!preg_match('#<[a-z/!]#i', $html)) {
$html = $this->convertPlainTextToHtml($html);
}
// Fremde Blockelemente (z. B. aus dem Backend-TinyMCE oder aus Word-Kopien)
// in Absätze überführen, damit der Text nicht zusammenläuft.
$html = (string) preg_replace('#<(div|h[1-6]|blockquote|section|article|header|footer|pre|address)\b[^>]*>#i', '<p>', $html);
$html = (string) preg_replace('#</(div|h[1-6]|blockquote|section|article|header|footer|pre|address)>#i', '</p>', $html);
$clean = $this->getSanitizer()->sanitize($html);
// Leere Hüllen (z. B. "<p></p>", "<p><br></p>") entfernen.
$clean = (string) preg_replace('#<p>(\s|&nbsp;|<br\s*/?>)*</p>#i', '', $clean);
return trim($clean);
}
/**
* Klartext-Variante für Excel & Co.: Block-Elemente werden zu Zeilenumbrüchen,
* Listenpunkte erhalten einen Spiegelstrich.
*/
public function toPlainText(?string $html): string
{
$html = (string) $html;
if ('' === trim($html)) {
return '';
}
$text = (string) preg_replace('#<li[^>]*>#i', "\n- ", $html);
$text = (string) preg_replace('#</(p|li|ul|ol)>#i', "\n", $text);
$text = (string) preg_replace('#<br\s*/?>#i', "\n", $text);
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = (string) preg_replace("/[ \t]+\n/", "\n", $text);
$text = (string) preg_replace("/\n{3,}/", "\n\n", $text);
return trim($text);
}
public function isEmpty(?string $html): bool
{
return '' === trim(strip_tags((string) $html));
}
private function convertPlainTextToHtml(string $text): string
{
$paragraphs = preg_split("/\R{2,}/", trim($text)) ?: [];
$html = '';
foreach ($paragraphs as $paragraph) {
$paragraph = trim($paragraph);
if ('' === $paragraph) {
continue;
}
$html .= '<p>'.nl2br(htmlspecialchars($paragraph, ENT_QUOTES | ENT_HTML5, 'UTF-8'), false).'</p>';
}
return $html;
}
private function getSanitizer(): HtmlSanitizer
{
if ($this->sanitizer instanceof HtmlSanitizer) {
return $this->sanitizer;
}
// Unbekannte Elemente werden "geblockt": das Tag verschwindet, der Textinhalt
// bleibt erhalten. Elemente, deren Inhalt nie als Text sinnvoll ist, werden
// komplett entfernt.
$config = (new HtmlSanitizerConfig())
->defaultAction(HtmlSanitizerAction::Block)
->withMaxInputLength(200000)
;
foreach (self::DROPPED_ELEMENTS as $element) {
$config = $config->dropElement($element);
}
foreach (self::ALLOWED_ELEMENTS as $element) {
$config = $config->allowElement($element, []);
}
return $this->sanitizer = new HtmlSanitizer($config);
}
}