228 lines
6.4 KiB
PHP
228 lines
6.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
class MarkdownService extends Prefab
|
|
{
|
|
private const ALLOWED_TAGS = [
|
|
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
'ul', 'ol', 'li', 'blockquote', 'pre', 'code',
|
|
'strong', 'em', 'a', 'img', 'hr', 'br',
|
|
];
|
|
|
|
private const ALLOWED_ATTRIBUTES = [
|
|
'a' => ['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 = '<div id="markdown-root">' . $html . '</div>';
|
|
|
|
$previous = libxml_use_internal_errors(true);
|
|
$document->loadHTML(
|
|
'<?xml encoding="utf-8" ?>' . $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);
|
|
}
|
|
}
|