Less home code more F3
This commit is contained in:
@@ -24,8 +24,9 @@ abstract class BaseController
|
||||
? $data['flash']
|
||||
: $this->pullFlash();
|
||||
|
||||
// Jeton CSRF stable par session : plus simple et plus robuste que le
|
||||
// pool précédent, tout en restant compatible multi-onglets.
|
||||
// On s'appuie sur Session(..., 'CSRF') pour la génération F3 du
|
||||
// jeton, mais on le persiste en session pour qu'il reste valide
|
||||
// entre la requête GET qui rend le formulaire et le POST suivant.
|
||||
$this->ensureCsrfToken();
|
||||
|
||||
$this->f3->mset($data + [
|
||||
@@ -34,7 +35,7 @@ abstract class BaseController
|
||||
'metaDescription' => null,
|
||||
'adminMode' => false,
|
||||
'currentUser' => $currentUser,
|
||||
'FORM_CSRF' => (string) $this->f3->get('SESSION.csrf_token'),
|
||||
'CSRF_TOKEN' => (string) $this->f3->get('SESSION.csrf_token'),
|
||||
]);
|
||||
|
||||
echo Template::instance()->render('layout.html');
|
||||
@@ -44,13 +45,15 @@ abstract class BaseController
|
||||
// stocke dans le hive — accessible partout, y compris les templates.
|
||||
protected function currentUser(): ?array
|
||||
{
|
||||
if (!$this->f3->exists('currentUser', $user)) {
|
||||
if (!$this->f3->exists('ctx.current_user_loaded')) {
|
||||
$userId = (int) ($this->f3->get('SESSION.user_id') ?? 0);
|
||||
$user = $userId > 0 ? (new User())->findById($userId) : null;
|
||||
|
||||
$this->f3->set('currentUser', $user);
|
||||
$this->f3->set('ctx.current_user_loaded', true);
|
||||
}
|
||||
|
||||
return $user;
|
||||
return $this->f3->get('currentUser');
|
||||
}
|
||||
|
||||
protected function requireAuth(): void
|
||||
@@ -94,7 +97,12 @@ abstract class BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
$this->f3->set('SESSION.csrf_token', bin2hex(random_bytes(32)));
|
||||
$seed = trim((string) ($this->f3->get('CSRF') ?? ''));
|
||||
if ($seed === '') {
|
||||
$seed = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
$this->f3->set('SESSION.csrf_token', $seed);
|
||||
}
|
||||
|
||||
private function pullFlash(): array
|
||||
|
||||
@@ -10,6 +10,11 @@ class MarkdownService extends Prefab
|
||||
'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);
|
||||
@@ -18,78 +23,205 @@ class MarkdownService extends Prefab
|
||||
}
|
||||
|
||||
$html = Markdown::instance()->convert($markdown);
|
||||
$html = strip_tags($html, self::ALLOWED_TAGS);
|
||||
$html = self::resolveImages($html, $media);
|
||||
$html = self::secureLinks($html);
|
||||
$document = $this->parseFragment($html);
|
||||
$this->sanitizeTree($document, $media);
|
||||
|
||||
return trim($html);
|
||||
return trim($this->renderFragment($document));
|
||||
}
|
||||
|
||||
// Résout les images media:filename et supprime les images externes.
|
||||
private static function resolveImages(string $html, Media $media): string
|
||||
private function parseFragment(string $html): DOMDocument
|
||||
{
|
||||
$f3 = Base::instance();
|
||||
$document = new DOMDocument('1.0', 'UTF-8');
|
||||
$wrapper = '<div id="markdown-root">' . $html . '</div>';
|
||||
|
||||
return preg_replace_callback('/<img\s[^>]*>/i', function (array $m) use ($f3, $media): string {
|
||||
if (!preg_match('/src="([^"]*)"/', $m[0], $s) || !str_starts_with($s[1], 'media:')) {
|
||||
return '';
|
||||
}
|
||||
$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);
|
||||
|
||||
$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 = $f3->encode($item['alt']);
|
||||
}
|
||||
|
||||
$url = $f3->encode($item['url']);
|
||||
$attrs = 'src="' . $url . '" alt="' . $alt . '"';
|
||||
|
||||
// width/height préviennent le layout shift au chargement.
|
||||
if ((int) $item['width'] > 0) {
|
||||
$attrs .= ' width="' . (int) $item['width'] . '"';
|
||||
}
|
||||
if ((int) $item['height'] > 0) {
|
||||
$attrs .= ' height="' . (int) $item['height'] . '"';
|
||||
}
|
||||
|
||||
return '<img ' . $attrs . ' loading="lazy" decoding="async">';
|
||||
}, $html) ?? $html;
|
||||
return $document;
|
||||
}
|
||||
|
||||
// Sécurise les liens : rel="noopener noreferrer" sur tous,
|
||||
// target="_blank" sur les liens externes uniquement.
|
||||
private static function secureLinks(string $html): string
|
||||
private function renderFragment(DOMDocument $document): string
|
||||
{
|
||||
return preg_replace_callback('/<a\s[^>]*>/i', function (array $m): string {
|
||||
if (!preg_match('/href="([^"]*)"/', $m[0], $h)) {
|
||||
return $m[0];
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$attrs = 'href="' . $h[1] . '" rel="noopener noreferrer"';
|
||||
private function dropDisallowedElement(DOMElement $element): void
|
||||
{
|
||||
$parent = $element->parentNode;
|
||||
if ($parent === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preg_match('~^https?://~i', $h[1])) {
|
||||
$attrs .= ' target="_blank"';
|
||||
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 (preg_match('/title="([^"]*)"/', $m[0], $t)) {
|
||||
$attrs .= ' title="' . $t[1] . '"';
|
||||
}
|
||||
if ($tag === 'a') {
|
||||
$this->sanitizeLink($element);
|
||||
return;
|
||||
}
|
||||
|
||||
return '<a ' . $attrs . '>';
|
||||
}, $html) ?? $html;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ @FORM_CSRF }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ @CSRF_TOKEN }}">
|
||||
|
||||
@@ -77,7 +77,7 @@ $f3->set('JAR', [
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
new Session();
|
||||
new Session(null, 'CSRF');
|
||||
|
||||
// ── Template ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user