- 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>
120 lines
4.0 KiB
PHP
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| |<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);
|
|
}
|
|
}
|