['href', 'title', 'rel', 'target'], 'img' => ['src', 'alt', 'width', 'height', 'loading', 'decoding'], ]; public function compile(string $markdown, Media $media): string { $markdown = trim($markdown); if ($markdown === '') { throw new RuntimeException('Ajoute du contenu avant de publier.'); } $html = Markdown::instance()->convert($markdown); $document = $this->parseFragment($html); $this->sanitizeTree($document, $media); return trim($this->renderFragment($document)); } private function parseFragment(string $html): DOMDocument { $document = new DOMDocument('1.0', 'UTF-8'); $wrapper = '
' . $html . '
'; $previous = libxml_use_internal_errors(true); $document->loadHTML( '' . $wrapper, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); libxml_use_internal_errors($previous); return $document; } private function renderFragment(DOMDocument $document): string { $root = $document->getElementById('markdown-root'); if ($root === null) { return ''; } $html = ''; foreach (iterator_to_array($root->childNodes) as $child) { $html .= $document->saveHTML($child); } return $html; } private function sanitizeTree(DOMDocument $document, Media $media): void { $root = $document->getElementById('markdown-root'); if ($root === null) { return; } $this->sanitizeChildren($root, $media); } private function sanitizeChildren(DOMNode $parent, Media $media): void { foreach (iterator_to_array($parent->childNodes) as $child) { if ($child instanceof DOMElement) { $tag = strtolower($child->tagName); if (!in_array($tag, self::ALLOWED_TAGS, true)) { $this->dropDisallowedElement($child); continue; } $this->sanitizeElement($child, $media); $this->sanitizeChildren($child, $media); } } } private function dropDisallowedElement(DOMElement $element): void { $parent = $element->parentNode; if ($parent === null) { return; } if (in_array(strtolower($element->tagName), ['script', 'style'], true)) { $parent->removeChild($element); return; } while ($element->firstChild !== null) { $parent->insertBefore($element->firstChild, $element); } $parent->removeChild($element); } private function sanitizeElement(DOMElement $element, Media $media): void { $tag = strtolower($element->tagName); $allowedAttributes = self::ALLOWED_ATTRIBUTES[$tag] ?? []; foreach (iterator_to_array($element->attributes) as $attribute) { if (!in_array(strtolower($attribute->name), $allowedAttributes, true)) { $element->removeAttributeNode($attribute); } } if ($tag === 'a') { $this->sanitizeLink($element); return; } if ($tag === 'img') { $this->sanitizeImage($element, $media); } } private function sanitizeLink(DOMElement $element): void { $href = trim((string) $element->getAttribute('href')); if (!$this->isAllowedHref($href)) { $this->unwrapElement($element); return; } $element->setAttribute('href', $href); $element->setAttribute('rel', 'noopener noreferrer'); if ($this->isExternalHttpUrl($href)) { $element->setAttribute('target', '_blank'); } else { $element->removeAttribute('target'); } } private function sanitizeImage(DOMElement $element, Media $media): void { $src = trim((string) $element->getAttribute('src')); if (!str_starts_with($src, 'media:')) { $element->parentNode?->removeChild($element); return; } $fileName = substr($src, 6); if ($fileName === '') { $element->parentNode?->removeChild($element); return; } $item = $media->findByFileName($fileName); if ($item === null) { throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.'); } $alt = trim((string) $element->getAttribute('alt')); if ($alt === '') { $alt = (string) ($item['alt'] ?? ''); } $element->setAttribute('src', (string) $item['url']); $element->setAttribute('alt', $alt); $element->setAttribute('loading', 'lazy'); $element->setAttribute('decoding', 'async'); $width = (int) ($item['width'] ?? 0); if ($width > 0) { $element->setAttribute('width', (string) $width); } else { $element->removeAttribute('width'); } $height = (int) ($item['height'] ?? 0); if ($height > 0) { $element->setAttribute('height', (string) $height); } else { $element->removeAttribute('height'); } } private function unwrapElement(DOMElement $element): void { $parent = $element->parentNode; if ($parent === null) { return; } while ($element->firstChild !== null) { $parent->insertBefore($element->firstChild, $element); } $parent->removeChild($element); } private function isAllowedHref(string $href): bool { if ($href == '') { return false; } if (str_starts_with($href, '#') || str_starts_with($href, '/')) { return true; } if (preg_match('~^(?:https?://|mailto:)~i', $href)) { return true; } return !preg_match('~^[a-z][a-z0-9+.-]*:~i', $href); } private function isExternalHttpUrl(string $href): bool { return (bool) preg_match('~^https?://~i', $href); } }