Less home code more F3
This commit is contained in:
@@ -17,238 +17,68 @@ class MarkdownService extends Prefab
|
||||
throw new RuntimeException('Ajoute du contenu avant de publier.');
|
||||
}
|
||||
|
||||
$markdown = self::normalizeMarkdown($markdown);
|
||||
$html = Markdown::instance()->convert($markdown);
|
||||
$html = self::sanitizeAndResolve($html, $media);
|
||||
$html = strip_tags($html, self::ALLOWED_TAGS);
|
||||
$html = self::resolveImages($html, $media);
|
||||
$html = self::secureLinks($html);
|
||||
|
||||
if (trim(strip_tags($html)) === '' && !preg_match('/<(img|video|audio|figure)[\s>]/i', $html)) {
|
||||
$fallback = nl2br(htmlspecialchars($markdown, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
|
||||
$html = '<p>' . str_replace('<br />', '</p><p>', $fallback) . '</p>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
return trim($html);
|
||||
}
|
||||
|
||||
// Reconstruction en liste blanche : les descendants d'une balise interdite
|
||||
// sont retraités récursivement avant d'être réinsérés.
|
||||
private static function sanitizeAndResolve(string $html, Media $media): string
|
||||
// Résout les images media:filename et supprime les images externes.
|
||||
private static function resolveImages(string $html, Media $media): string
|
||||
{
|
||||
$source = new DOMDocument('1.0', 'UTF-8');
|
||||
$clean = new DOMDocument('1.0', 'UTF-8');
|
||||
$cleanBody = $clean->createElement('body');
|
||||
$clean->appendChild($cleanBody);
|
||||
|
||||
$previousUseInternalErrors = libxml_use_internal_errors(true);
|
||||
$source->loadHTML('<?xml encoding="UTF-8"><body>' . $html . '</body>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previousUseInternalErrors);
|
||||
|
||||
$sourceBody = $source->getElementsByTagName('body')->item(0);
|
||||
if (!$sourceBody instanceof DOMElement) {
|
||||
return '';
|
||||
}
|
||||
|
||||
self::appendSanitizedChildren($sourceBody, $cleanBody, $clean, $media);
|
||||
|
||||
$out = '';
|
||||
for ($i = 0; $i < $cleanBody->childNodes->length; $i++) {
|
||||
$child = $cleanBody->childNodes->item($i);
|
||||
if ($child !== null) {
|
||||
$out .= $clean->saveHTML($child);
|
||||
return preg_replace_callback('/<img\s[^>]*>/i', function (array $m) use ($media): string {
|
||||
if (!preg_match('/src="([^"]*)"/', $m[0], $s) || !str_starts_with($s[1], 'media:')) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return trim($out);
|
||||
$fileName = substr($s[1], 6);
|
||||
if ($fileName === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$item = $media->findByFileName($fileName);
|
||||
if ($item === null) {
|
||||
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
|
||||
}
|
||||
|
||||
// L'alt du Markdown est déjà échappé par le parser F3.
|
||||
// Le fallback vers l'alt de la base nécessite un échappement.
|
||||
$alt = '';
|
||||
if (preg_match('/alt="([^"]*)"/', $m[0], $a)) {
|
||||
$alt = $a[1];
|
||||
}
|
||||
if ($alt === '') {
|
||||
$alt = htmlspecialchars($item['alt'], ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
$url = htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return '<img src="' . $url . '" alt="' . $alt . '" loading="lazy" decoding="async">';
|
||||
}, $html) ?? $html;
|
||||
}
|
||||
|
||||
private static function appendSanitizedChildren(DOMNode $sourceParent, DOMNode $targetParent, DOMDocument $target, Media $media): void
|
||||
// Sécurise les liens : rel="noopener noreferrer" sur tous,
|
||||
// target="_blank" sur les liens externes uniquement.
|
||||
private static function secureLinks(string $html): string
|
||||
{
|
||||
$children = [];
|
||||
for ($i = 0; $i < $sourceParent->childNodes->length; $i++) {
|
||||
$child = $sourceParent->childNodes->item($i);
|
||||
if ($child !== null) {
|
||||
$children[] = $child;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($children as $child) {
|
||||
if ($child instanceof DOMComment) {
|
||||
continue;
|
||||
return preg_replace_callback('/<a\s[^>]*>/i', function (array $m): string {
|
||||
if (!preg_match('/href="([^"]*)"/', $m[0], $h)) {
|
||||
return $m[0];
|
||||
}
|
||||
|
||||
if ($child instanceof DOMText) {
|
||||
$targetParent->appendChild($target->createTextNode($child->nodeValue ?? ''));
|
||||
continue;
|
||||
$attrs = 'href="' . $h[1] . '" rel="noopener noreferrer"';
|
||||
|
||||
if (preg_match('~^https?://~i', $h[1])) {
|
||||
$attrs .= ' target="_blank"';
|
||||
}
|
||||
|
||||
if (!$child instanceof DOMElement) {
|
||||
continue;
|
||||
if (preg_match('/title="([^"]*)"/', $m[0], $t)) {
|
||||
$attrs .= ' title="' . $t[1] . '"';
|
||||
}
|
||||
|
||||
self::appendSanitizedElement($child, $targetParent, $target, $media);
|
||||
}
|
||||
}
|
||||
|
||||
private static function appendSanitizedElement(DOMElement $sourceElement, DOMNode $targetParent, DOMDocument $target, Media $media): void
|
||||
{
|
||||
$tag = strtolower($sourceElement->tagName);
|
||||
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
|
||||
self::appendSanitizedChildren($sourceElement, $targetParent, $target, $media);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($tag === 'img') {
|
||||
$image = self::buildSanitizedImage($sourceElement, $target, $media);
|
||||
if ($image !== null) {
|
||||
$targetParent->appendChild($image);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$cleanElement = $target->createElement($tag);
|
||||
self::sanitizeAttributes($sourceElement, $cleanElement);
|
||||
$targetParent->appendChild($cleanElement);
|
||||
self::appendSanitizedChildren($sourceElement, $cleanElement, $target, $media);
|
||||
}
|
||||
|
||||
private static function sanitizeAttributes(DOMElement $sourceElement, DOMElement $targetElement): void
|
||||
{
|
||||
if ($targetElement->tagName !== 'a') {
|
||||
return;
|
||||
}
|
||||
|
||||
$href = self::sanitizeHref((string) $sourceElement->getAttribute('href'));
|
||||
if ($href !== null) {
|
||||
$targetElement->setAttribute('href', $href);
|
||||
$targetElement->setAttribute('rel', 'noopener noreferrer');
|
||||
if (preg_match('~^https?://~i', $href) === 1) {
|
||||
$targetElement->setAttribute('target', '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
$title = self::sanitizeAttributeValue((string) $sourceElement->getAttribute('title'));
|
||||
if ($title !== null) {
|
||||
$targetElement->setAttribute('title', $title);
|
||||
}
|
||||
}
|
||||
|
||||
private static function buildSanitizedImage(DOMElement $sourceElement, DOMDocument $target, Media $media): ?DOMElement
|
||||
{
|
||||
$src = trim((string) $sourceElement->getAttribute('src'));
|
||||
if ($src === '' || !str_starts_with($src, 'media:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fileName = substr($src, 6);
|
||||
if ($fileName === '' || preg_match('/[\x00-\x1F\x7F]/u', $fileName) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$item = $media->findByFileName($fileName);
|
||||
if ($item === null) {
|
||||
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
|
||||
}
|
||||
|
||||
$image = $target->createElement('img');
|
||||
$image->setAttribute('src', (string) $item['url']);
|
||||
$image->setAttribute('loading', 'lazy');
|
||||
$image->setAttribute('decoding', 'async');
|
||||
|
||||
if ($sourceElement->hasAttribute('alt')) {
|
||||
$image->setAttribute('alt', self::sanitizeAttributeValue((string) $sourceElement->getAttribute('alt'), true) ?? '');
|
||||
} elseif ((string) $item['alt'] !== '') {
|
||||
$image->setAttribute('alt', (string) $item['alt']);
|
||||
} else {
|
||||
$image->setAttribute('alt', '');
|
||||
}
|
||||
|
||||
$title = self::sanitizeAttributeValue((string) $sourceElement->getAttribute('title'));
|
||||
if ($title !== null) {
|
||||
$image->setAttribute('title', $title);
|
||||
}
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
private static function sanitizeHref(string $href): ?string
|
||||
{
|
||||
$href = trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
if ($href === '' || preg_match('/[\x00-\x1F\x7F]/u', $href) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('~^(https?://|mailto:|tel:)~i', $href) === 1) {
|
||||
return $href;
|
||||
}
|
||||
|
||||
if (self::isSafeRelativeHref($href)) {
|
||||
return $href;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function isSafeRelativeHref(string $href): bool
|
||||
{
|
||||
if ($href === '/') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (str_starts_with($href, '//')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('~^(?:/[^/]|\./|\.\./|#|\?)~', $href) === 1;
|
||||
}
|
||||
|
||||
private static function sanitizeAttributeValue(string $value, bool $allowEmpty = false): ?string
|
||||
{
|
||||
$value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$value = trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value));
|
||||
|
||||
if ($value === '' && !$allowEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function normalizeMarkdown(string $markdown): string
|
||||
{
|
||||
$markdown = str_replace(["\r\n", "\r"], "\n", $markdown);
|
||||
$lines = explode("\n", $markdown);
|
||||
$normalized = [];
|
||||
$inFence = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^\s*(```|~~~)/', $line) === 1) {
|
||||
$inFence = !$inFence;
|
||||
$normalized[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inFence) {
|
||||
$normalized[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
$isBlank = trim($line) === '';
|
||||
$isListItem = preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $line) === 1;
|
||||
$previous = $normalized[count($normalized) - 1] ?? null;
|
||||
$previousIsBlank = $previous === null || trim($previous) === '';
|
||||
$previousIsListItem = $previous !== null && preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $previous) === 1;
|
||||
|
||||
if ($isListItem && !$previousIsBlank && !$previousIsListItem) {
|
||||
$normalized[] = '';
|
||||
}
|
||||
|
||||
if (!$isBlank && !$isListItem && $previousIsListItem) {
|
||||
$normalized[] = '';
|
||||
}
|
||||
|
||||
$normalized[] = $line;
|
||||
}
|
||||
|
||||
return trim(implode("\n", $normalized));
|
||||
return '<a ' . $attrs . '>';
|
||||
}, $html) ?? $html;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user